Compare commits

...

280 Commits

Author SHA1 Message Date
louispaulb
815146b70b Planification grille : retrait de l'axe de temps + la bande n'intercepte plus le clic/drag de la cellule
- Axe de temps (hdr-ruler 0..24) retiré des en-têtes de jour (inutile dans la grille ; encombrant).
- La bande d'occupation `.tl` ne stoppe plus la propagation (plus de @mousedown.stop/@click.stop) → le
  clic/drag sur la cellule remonte à `<td>` → onCellClick (menu d'horaire) + onDown/onEnter (sélection) refonctionnent.
- L'éditeur de journée s'ouvre désormais en cliquant un BLOC de job (.tl-blk, @click.stop) — pas toute la
  bande. Donc : clic sur un bloc coloré = éditer la tournée ; clic ailleurs dans la cellule = modifier l'horaire.
  Le tooltip d'occupation (survol) reste actif. Curseur/hover sur les blocs cliquables.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:02:39 -04:00
louispaulb
2665a6a2da Planification éditeur journée : déplacements en pointillés + clic pin = détails + clic ligne = recentre carte
- Barre timeline : l'espace entre 2 jobs (le déplacement) est rendu en POINTILLÉS (au lieu du gris vide),
  tooltip « 🚗 déplacement ». dayTravelSegs() = gaps entre packedDay[i].end et [i+1].start.
- Carte : clic sur un pin → POPUP avec détails du job (n°, sujet, heure, client) ; curseur main au survol.
- Liste : clic sur une ligne → recentre la carte (easeTo zoom 14) sur ce job, en plus d'ouvrir le détail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:50:52 -04:00
louispaulb
307fb4cea5 Planification : carte de journée INTERACTIVE (Mapbox GL) — itinéraire routier réel + zoom/déplacement
Remplace la mini-image statique (segments à vol d'oiseau) par une carte Mapbox GL :
- Itinéraire ROUTIER réel via l'API Directions (geometries=geojson) tracé sur la carte (halo + ligne).
- Pins numérotés dans l'ordre de tournée (cercle coloré par compétence + numéro).
- Navigable : zoom molette + boutons NavigationControl (+/-), déplacement (pan), ajustée au territoire (fitBounds).
- Lifecycle : init à l'ouverture du dialogue (après anim + resize), refresh débouncé au réordonnancement
  (re-trace l'itinéraire), destruction à la fermeture (pas de fuite). mapbox-gl chargé en CDN (comme le Dispatch).
- Avertissement « N sans coords » conservé. Validé : Directions OK (géométrie 392 pts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:44:22 -04:00
louispaulb
d56800805e Pont : import basé sur l'ADRESSE DE SERVICE (delivery), pas la facturation (compte)
Problème de fond : quand ticket.delivery_id ne se joignait pas, le pont retombait sur l'adresse de
FACTURATION du compte (résidence) → sujet + géocodage faux. Or les 124 tickets ouverts-3301 ont tous un
delivery côté compte (98 via ticket.delivery_id, 26 via le compte).

- fetchTargoTickets : résolution delivery robuste — COALESCE(ticket.delivery_id, delivery du compte AVEC
  coords [plus récent], delivery du compte [plus récent]) → l'adresse de SERVICE est toujours disponible.
- buildJob préfère déjà svcAddr (delivery) au billAddr → sujet + géocodage utilisent le service.
- Rafraîchit le `subject` des jobs encore au pool (open + non assigné) pour refléter l'adresse de service
  (corrige les anciens sujets basés sur la facturation) ; ne touche pas un job déjà dispatché.

Résultat (re-sync) : delivery 26→37, no_coords 6→1, 0 erreur. Jobs ouverts affichent l'adresse de service
(« Camping Sandysun · 32 Bellevue », « Lac des pins · 25 Érable », « Franklin · 35 rue Hilltop »…).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:36:17 -04:00
louispaulb
f804c2b49d Pont : géoloc camping (fixe) sur les Dispatch Jobs — l'adresse de service ≠ résidence du client
Symptôme : un job de camping (« Lac des pins | Anton Rimerov ») pointait sur la RÉSIDENCE du client
(428 Rue George, Lasalle = 45.58,-73.73) au lieu du camping. Le pont géocodait l'adresse de compte.

- buildJob : détection camping en PRIORITÉ MAX via le registre camping_registry — signal = sujet (label
  explicite, prioritaire) puis ville/adresse de delivery. Garde-fou : le texte doit contenir « camping » OU
  un mot-clé de LIEU spécifique (évite les faux positifs de patronyme, ex. « Daniel Dauphinais »). coord_src='camping'.
  La branche update fait écraser les coords existantes par le camping (comme delivery). 20 jobs ouverts re-coordonnés.
- camping_dispatch_backfill.sql : corrige les jobs DÉJÀ dispatchés (que le sync ne re-traite plus car le ticket
  legacy a quitté le pool ouvert-3301) → 4 Lac des Pins + 2 SandySun. Anton Rimerov/Germaine Thibert → 45.0624,-73.9113 ✓.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:30:11 -04:00
louispaulb
2b9a863d39 Conformité : repli « centre du code postal / ville » pour les unmatched restants (statut 'area')
Dernier recours quand l'adresse exacte est introuvable : placer le Service Location au CENTROÏDE
(rqa_addresses) de son code postal (préféré) sinon de sa ville → le job apparaît dans le bon secteur.
- Hub : applyAreaFallback() (CTE centroïdes CP/ville, index-friendly) + POST /address/conformity/apply-area.
  Statut 'area', linked_address '≈ centre <CP/ville>'. Hors-QC/junk (absents de rqa_addresses) restent unmatched.
- Ops : carte stat « ≈ Secteur (CP/ville) » + bouton « ≈ Centre CP/ville (reste) » dans la page Conformité.

Exécuté : 317 placés (303 par code postal, 14 par ville) → unmatched 365 → 48 (Toronto/boîtes postales/junk).
État final : validated 16 257 · review 489 · area 317 · unmatched 48 → 99,7 % des services ont une position.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:14:41 -04:00
louispaulb
ec6a317933 Campings : gestion du registre + réapplication self-service depuis Ops
Rend le mécanisme réutilisable (« faire de même pour tous les campings ») :
- Hub (address-conformity.js) : GET /address/conformity/campings (registre + nb lots par camping),
  POST /campings (upsert {keyword,name,address,lat,lon} → applique direct), POST /campings/apply (réappliquer).
  applyCampings() = UPDATE des lots (match ville normalisée) → géoloc fixe du camping.
- Ops (page Conformité adresses) : section « Campings — géoloc de remplacement fixe » : table du registre
  (nom, adresse principale, GPS→Google Maps, nb lots) + formulaire d'ajout (nom/mot-clé/adresse/lat/lon)
  qui ajoute ET applique, + bouton « réappliquer ». api/address.js : campingsList/Upsert/Apply.

→ Pour un nouveau camping : on saisit son adresse principale + GPS, tous ses lots pointent dessus (le tech
navigue au camping). Registre seedé : Lac des Pins, Dauphinais, SandySun, Frontière, Ensoleillé.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:07:15 -04:00
louispaulb
d6453757d1 Campings : registre + géoloc de remplacement FIXE sur tous les lots (1405 corrigés)
Problème : pour un lot de camping, l'adresse du Service Location = souvent la RÉSIDENCE du client, et la
rue interne (ex. « 2 rue Canard, Lac des Pins ») n'est pas dans le RQA → géoloc fausse. Solution data-driven :
table `camping_registry` (mot-clé ville → nom + adresse principale + GPS fixe), coords relevées dans le
legacy delivery. Application : force lat/long du camping sur tous les lots (match ville normalisée), garde
address_line (n° de terrain visible), linked_address = le camping, statut validated.

Appliqué : Lac des Pins 1242 · Dauphinais 134 · SandySun 28 · Frontière 1 (+ Ensoleillé en registre).
Idempotent + ré-applicable. scripts/migration/camping_registry.sql.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:04:03 -04:00
louispaulb
50d877b49f Planification : minimap du territoire (pins+tracé) dans l'éditeur de journée + retrait du sélecteur priorité
- Minimap Mapbox Static ajoutée sous la timeline : pins numérotés dans l'ordre de tournée + tracé reliant
  les arrêts, ajustée auto au territoire des jobs → on VÉRIFIE d'un coup d'œil que chaque arrêt tombe à la
  bonne adresse (un pin mal placé = coord à corriger via Conformité adresses). Clic → carte interactive (Dispatch).
  Indique « N sans coords (absent de la carte) » le cas échéant. Helper encodePolyline (precision 5) pour le tracé.
- Sélecteur de priorité retiré de chaque ligne (défaut « Moyenne » conservé en donnée, géré au Dispatch) → gain de place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:54:53 -04:00
louispaulb
48c2f53d18 Phase 1 (hygiène) : utils partagés + logique pure testable + observabilité erp + 1ers tests
Modularisation / dé-duplication :
- lib/util/text.js : `norm` canonique partagé (remplace 2 ré-implémentations : address-db, legacy-dispatch-sync).
- lib/util/legacy-parse.js : parseurs/mapping PURS du pont (DEPT_JOBTYPE, DUR, jobType, prio, tzDate,
  startTime, coord) extraits hors I/O → testables en isolation, sans pg/mysql/erp.
- legacy-dispatch-sync + address-db importent ces utils (pont vérifié en prod : preview OK, 0 erreur).

Observabilité (sûr, additif, 1 seul point) :
- erp.js create/update/remove : log de l'échec à la SOURCE quand HTTP≥400 → toutes les écritures ERPNext
  silencieuses des 50+ appelants sont désormais tracées, SANS changer aucun flux de contrôle.

Tests (fondation) :
- vitest + npm test ; test/util.test.js : 19 tests verts sur norm + coord(bornes QC)/prio/startTime/jobType/tzDate.
  Tournent sans installer les deps lourdes du hub (modules purs).

Aligné docs/architecture/VISION.md (P0 hygiène). Suite : audit r.ok des appelants financiers (payments/contracts)
en revue supervisée ; CI/CD minimal (Gitea Actions lint+test) ; décomposition des god-files (Phase 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:36:41 -04:00
louispaulb
f33f7a6309 Optimisation (consolidation helpers address) + doc Vision/modularisation
Optimisation sûre (vérifiée, 0 régression) :
- helpers.js : `cors()` partagé (en-têtes CORS génériques) au lieu de 2 copies locales.
- address-conformity.js : réutilise `pool` (address-db) + `cors` (helpers) au lieu de redéfinir un Pool +
  cors → 1 seul client pg local partagé pour rqa_addresses/fiber.
- address-validate.js : utilise helpers.cors.

docs/architecture/VISION.md (NOUVEAU) — vision + plan de modularisation + roadmap d'optimisation, fondé sur
un audit chiffré (hub 58 modules/23k lignes, Ops 45k lignes, god-files identifiés). Découpe en 9 domaines
(bounded contexts), principe « source de vérité + validation à la saisie + lien stable » (modèle Adresses
généralisé à Client/Device/Service), optimisations P0/P1/P2, métriques de succès. Complète les docs
architecture existants + ENGINEERING_PRACTICES.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:28:54 -04:00
louispaulb
27bbcf43d0 Page Ops « Conformité des adresses » — source de vérité unique pour résoudre le backlog
Plus besoin de re-chercher avec un processus complexe : une page liste les adresses de service non
conformes (review/unmatched) avec leur proposition AQ canonique, et permet de RÉSOUDRE une fois (persisté) :
- Approuver : la proposition AQ devient officielle (validated, coords RQA).
- Corriger : recherche AQ locale (rqa_addresses + fibre) → lier la bonne adresse.
- GPS : saisir/coller lat,long (relevé sur map.targointernet.com qui a la géoloc des unités de camping)
  + lien direct « voir sur la carte » par ligne.
- Rejeter : pas d'adresse civique (boîte postale/hors-QC) → 'no_address'.

Tri par type (camping / civique à corriger / à confirmer / non-adresse) + stats + recherche + pagination.

Backend : lib/address-conformity.js (GET stats|list|candidates, POST resolve) sur le Postgres LOCAL,
routé /address/conformity/* (server.js). Front : api/address.js + pages/AddressConformityPage.vue + route
/conformite-adresses + entrée nav « Conformité adresses » (icône MapPinned, requires view_settings).

État courant : validated 15 195 · review 1 366 · unmatched 550 (camping 540 / civique 333 / non-adresse 93).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:06:55 -04:00
louispaulb
0edf2fe3df SL normalisation passe 2 : récupération GPS par numéro+ville (sans contrainte postale)
scripts/migration/normalize_service_locations_pass2.sql : pour les SL restées review/unmatched après la
passe 1 (code postal+numéro), rematch par NUMÉRO + VILLE (trigram, normalisation St→Saint) + meilleure rue
par similarité ≥0.30 (rue seule, pas ville-incluse pour éviter le gonflement) → upgrade en validated avec
coords RQA réelles. Récupère les SL au code postal erroné/manquant mais ville valide (ex. Athelstan/Ch Ridge).

Résultat : +264 validated (15 195), unmatched 766→550. GPS sur 17 036/17 111 services (99,6%),
dont 15 195 (89%) rooftop AQ. Les 75 sans GPS = boîtes postales/hors-QC/placeholders/sobriquets camping
(pas de position de service réelle). Idempotent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:19:37 -04:00
louispaulb
912359f38b Normalisation AQ des 17k Service Locations (lien RQA + statut, adresse originale préservée)
scripts/migration/normalize_service_locations.sql : rattache chaque Service Location à l'adresse
canonique Adresses Québec (table locale rqa_addresses) par CODE POSTAL + NUMÉRO (clé sélective indexée)
+ meilleure rue par similarité désaccentuée (unaccent + pg_trgm). Les 2 tables étant colocalisées dans
la db ERPNext, tout se fait en SQL (~26 s pour 16 345 lignes, pas d'aller-retour applicatif).

Remplit (sans toucher address_line/city/postal_code = origine préservée → table de translation) :
- aq_address_id = rqa_addresses.id (clé de translation locale ; ⚠ id LOCAL, pas l'uuid AQ officiel absent
  de la table locale)
- linked_address = adresse canonique conforme (address_full)
- address_validation_status = validated (sim≥0.20) | review (sim<0.20, ex. surnoms de camping) | unmatched
- latitude/longitude raffinées seulement si validated

Résultat : validated 14 931 (87%) · review 1 414 (8%) · unmatched 766 (4,5%) → 16 345 liés (95,5%).
Idempotent (ne traite que les lignes 'pending'). Couvre la tâche « backfill normalisation 17k SL ».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:59:21 -04:00
louispaulb
a510ac3848 Recherche d'adresses : base LOCALE (Postgres ERPNext) au lieu du Supabase cloud externe
Le hub n'appelle plus rddrjzptzhypltuzmere.supabase.co. La base RQA + dispo fibre est DÉJÀ locale
dans le Postgres ERPNext (rqa_addresses 5,24M + fiber_availability 21,6k jointes par address_id),
le hub y accède (réseau erpnext_erpnext + module pg).

- NOUVEAU lib/address-db.js : recherche locale. Phase 1 (civique présent) = filtre numero btree +
  mots de rue → ~20-150 ms ; Phase 2 (sans civique) = word_similarity (`<%` indexable GIN) au lieu de
  similarity() plein (24-76 s sur 5,24M !) → ~700 ms, dans une txn SET LOCAL (seuil 0.6 + statement_timeout 4s).
  Renvoie 2 formes : searchLocal (mappée, compat historique) + searchRaw (colonnes brutes de la fonction).
- address-search.js : searchAddresses + searchAddressesRpc délèguent à address-db (plus aucun appel Supabase).
  → onboarding (/address/validate), checkout (/api/address-search) ET le pont (géocodage) passent en LOCAL.
- address-validate.js : endpoints PUBLICS pour le site web (CORS) — POST /rpc/search_addresses (compat
  Supabase RPC, tableau direct) + GET /address/search — servis depuis le PG local (fiber_available inclus).
- server.js : route /rpc/ → address-validate.

Résultat pont (vérifié) : couverture 112/125 (vs 109 via Supabase), rqa_geocode 8→25 (table locale plus
complète + search_text désaccentué), Mapbox 37→23, no_coords 16→13, 0 erreur. Le local est meilleur.
Env hub : ADDR_DB_* dans /opt/targo-hub/.env (défauts erpnext-db-1/_eb65bdc0c4b1b2d6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:46:47 -04:00
louispaulb
b6831a1e48 Pont legacy : géocodage RQA via la recherche TRIGRAM (RPC search_addresses) + garde-fou anti-faux-positif
- address-search.js : expose searchAddressesRpc() → RPC Postgres `search_addresses` (pg_trgm), la MÊME
  recherche que l'autocomplete de disponibilité fibre. Trouve les rues que l'ilike manquait (générique géré
  par la colonne odonyme_recompose_long + phase 2 trigram), priorise les CP J0L/J0S (territoire).
- geocodeRQA() (bridge) bascule de l'ilike vers la RPC. Garde-fou : la phase 2 trigram dérive quand le
  civique est absent du RQA (« 2245 René-Vinet » → « Rue Grenet, Montréal »). On n'accepte un résultat que si
  le civique concorde + un token de nom de rue correspond + (territoire J0L/J0S OU CP/ville legacy concordants).
  Vérifié sur les données réelles : accepte 494 Av Curry / 3055 Routhier / 228 Principale / 61 Jean-François ;
  rejette René-Vinet→Grenet/Panet, chemin Ridge→Ferme, rue West→Perras (bons faux positifs écartés).
- Le faible compte RQA (8) = haute précision (l'ilike comptait 17 dont des faux positifs). Mapbox couvre le
  reste (rues neuves/civiques absents) ; ~109/125 (87 %) coordonnés ; les « aucune » = campings/villes mal écrites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:05:14 -04:00
louispaulb
2c3d7e9814 Pont legacy : coords GPS fiables (delivery→SL→RQA→Mapbox) + routage routier réel (Mapbox Matrix)
Pont (legacy-dispatch-sync.js) :
- Import des coordonnées par job via cascade : table legacy `delivery` (point de service exact,
  JOIN ticket.delivery_id) > Service Location ERPNext > géocodage RQA > géocodage Mapbox.
  Validation bornes Québec (coord()). Couverture 153/172 (89%).
- Géocodage RQA corrigé : retrait du générique de voie (Rue/Rang/Chemin absent de
  odonyme_recompose_normal) + code postal non accolé au terme (sinon ilike ne matche jamais).
- Repli Mapbox geocoding pour rues trop récentes pour le RQA (MAPBOX_TOKEN).
- Backfill + UPGRADE : coords delivery écrasent des coords SL moins précises (jamais l'inverse).
- Comptabilité honnête : vérifie r.ok sur create/update (erp ne throw pas) → errors + error_samples.
- Verrou de sérialisation sync() : tick + runs manuels ne se chevauchent plus (frappe_pg).
- Subject tronqué à 140 (champ Data) → corrige CharacterLengthExceededError sur jobs sans SL.
- Observabilité : coord_src tally + error_samples dans le résumé.

Ops Planification (éditeur de journée) :
- travelBetween() consulte une matrice Mapbox Matrix chargée à l'ouverture (loadDayRoute) →
  temps de trajet ROUTIERS RÉELS ; réordonnancement instantané sans nouvelle requête.
  Repli haversine si Mapbox indispo. Indicateur 🚗 réel vs 📏 vol d'oiseau.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:43:34 -04:00
louispaulb
0298f414ed feat(planif): éditeur journée = planificateur de tournée (heures recalculées, pas d'overlap, RDV verrouillables, détails)
- FIX overlap/ordre : packedDay recalcule les heures depuis la SÉQUENCE (ordre liste) + durées + transport ;
  le timeline et les heures affichées en découlent → réordonner/allonger repousse les suivants, plus d'overlap
- RDV à heure FIXE : verrou par job (🔒, défaut = booking_status 'Confirmé') → garde son heure ; flexibles = enchaînés
- clic sur un job → détails (legacy_detail : dates + description) pour juger urgence/durée ; + tooltip au survol
- save persiste start_time recalculé (+ route_order/priority/duration_h) via reorder-jobs
- hub occupancy renvoie booking_status/legacy_detail/legacy_id ; reorder-jobs accepte start_time
- fix collision fmtH → fmtHM (HH:MM padded)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:56:26 -04:00
louispaulb
bae6771b34 feat(planif): éditeur journée — réordonnancement fiable (flèches+drop) + durée éditable (min) + temps de transport
- FIX réordonnancement : flèches ↑↓ (fiable) + drag-drop basé sur le DROP (au lieu du splice live jittery)
- durée éditable par job en MINUTES (pas de 5, best practice précision) → persistée via reorder-jobs (duration_h)
- temps de transport estimé entre 2 jobs (haversine sur coords Service Location, 40km/h + 5min) affiché entre les lignes
  → en attendant la géoloc live (Capacitor background-geolocation, noté pour plus tard)
- hub : occupancyByTechDay renvoie lat/lon par job ; reorder-jobs accepte duration_h ; total h en pied

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:41:28 -04:00
louispaulb
c9fbbdbe9e feat(planif): éditeur de JOURNÉE contextuel au clic sur le progressbar (drag-drop réordonner + retirer)
- clic sur le progressbar → q-dialog ciblé sur le tech×jour (garde le contexte de la grille derrière) :
  timeline visuelle (blocs colorés par compétence) + liste éditable des jobs
- réordonnancement par DRAG-DROP (dragstart/dragover/dragend → route_order) + sélecteur de priorité + Enregistrer
- retrait d'un job (✕ → hub POST /roster/unassign-job : assigned_tech null, status open → retour au pool)
- bouton « Dispatch » comme échappatoire vers le tableau complet (gotoDispatch)
- réutilise occupancy/cellBands/cellBlocks/blockStyle + reorderJobs ; best-practice détail-drawer (pas de navigation pleine page)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:29:55 -04:00
louispaulb
455a66aeb9 refactor(planif): clic progressbar → timeline éditable Dispatch (tech+jour) au lieu du popup maison
- réutilisation max + cohérence : le clic sur le progressbar ouvre le tableau Dispatch focalisé sur
  le tech + le jour cliqué (gotoDispatch(t, d.iso)) = LE timeline éditable (drag-drop réordonner, supprimer/désaffecter)
- retire le popup cellJobsMenu (réordonner/priorité) → règle aussi le chevauchement avec l'infobulle mouseover
- (endpoint /roster/reorder-jobs conservé, réutilisable ; le réordonnancement se fait désormais côté Dispatch)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:19:59 -04:00
louispaulb
b7b7da783b feat(dispatch): date d'ouverture + MàJ en tête du détail ticket (mouseover) pour juger la fermeture
- pont : SELECT date_create+last_update ; legacy_detail préfixé « 🗓 Ouvert <date> · MàJ <date> » puis description.
  Backfill = réécrit legacy_detail si différent (idempotent). Visible dans le mouseover du panneau roster + le détail Dispatch.
  Ex. révélé : LEG-111325 ouvert 2021 jamais touché → candidat fermeture évident.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:07:15 -04:00
louispaulb
c8377a208a fix(dispatch): pont lisait un réplica figé (avril) + auto-fermeture des DJ dont le ticket legacy est closed
- ROOT CAUSE : LEGACY_DB_HOST='legacy-db' = réplica figé au 2026-04-07 → tickets fermés/réassignés depuis
  paraissaient encore ouverts (ex. 244659). Fix infra : .env LEGACY_DB_HOST=10.100.80.100 (DB live, SELECT-only).
- closeResolved() : tout DJ issu du pont (open/assigned/On Hold) dont le ticket legacy est 'closed' → status 'Completed'.
  Appelé à chaque sync + route POST /dispatch/legacy-sync/close-resolved. Résultat 1er run live : 125 ouverts réels,
  102 créés, 44 fermés (dont LEG-244659). NB In Progress non touché.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:03:10 -04:00
louispaulb
a7a428f261 feat(planif): tri du panneau flottant « Jobs à assigner » (groupe/compétence/date/ville/priorité)
- assignSort + assignGroups regroupe/trie selon le mode (défaut = groupe parent-enfant) ;
  ajout du tri par COMPÉTENCE (demandé) + date/ville/priorité (jobCity = dernier segment adresse ou « Ville | » du sujet)
- barre de tri dans le panneau (hors zone de drag) + en-tête de groupe par label ; indentation enfant seulement en mode groupe

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:48:44 -04:00
louispaulb
368e22d57e feat(planif): blocs cellule colorés par compétence + menu réordonner/re-prioriser au clic sur le progressbar
- Blocs d'occupation = 1 job, coloré par la COULEUR DE SA COMPÉTENCE (palette skills éditable),
  au lieu du dégradé vert→rouge par taux. Hub occupancyByTechDay attache skill+job à chaque bloc
  (skillForJob||deptToSkill) ; blockStyle → getTagColor(blk.skill).
- Clic sur le progressbar (.tl, @click.stop+@mousedown.stop) → menu : liste des jobs du tech×jour avec
  réordonnancement (flèches → route_order) + re-priorisation (select) + Enregistrer.
  Hub POST /roster/reorder-jobs (route_order/priority, séquentiel) ; tri occupancy par route_order.
- Clic HORS du progressbar dans la cellule → menu shift inchangé (créer/modifier).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:44:22 -04:00
louispaulb
5e57b72a8f feat(dispatch): couleur ticket = couleur skill + fil complet du ticket + tri pool (date/ville/priorité)
- Couleurs liées aux skills (éditable/cohérent) : hub deptToSkill() déduit une compétence du type legacy
  → /roster/unassigned-jobs renvoie required_skill ; PlanificationPage colore la carte par getTagColor(required_skill)
  (même couleur que le chip skill) ; bordure 5px
- Fil complet du ticket : hub /dispatch/legacy-sync/ticket-thread (ticket_msg + auteur staff, HTML nettoyé) ;
  api legacyTicketThread ; RightPanel bouton « 💬 Voir le fil / commentaires » (chargé au clic, messages+auteurs+dates)
- Order-by du pool dispatch : useBottomPanel.bottomSort (date|city|priority) + dropdown ⇅ dans BottomPanel
  (ville = 2e segment adresse / token sujet avant |)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:37:45 -04:00
louispaulb
6f709dd8e1 feat(dispatch): réconciliation + heartbeat + détails ticket dans Ops + couleurs panneau roster
#1 « ne rien échapper » :
- /dispatch/legacy-sync/reconcile : compare legacy(3301,open) ↔ Dispatch Jobs → missing/orphan (70↔70, 0/0)
- /dispatch/legacy-sync/status : heartbeat (last_sync + stale) pour Uptime-Kuma

Détails ticket dans Ops (bug signalé) :
- pont extrait le 1er message du fil legacy (stripHtml) → champ legacy_detail (backfill 70)
- store legacyDetail ; RightPanel : section « Détails du ticket » ; tooltip dans le panneau roster

Couleurs panneau roster (bug signalé) :
- /roster/unassigned-jobs renvoie job_type/legacy_dept/priority/legacy_* ; PlanificationPage colore
  chaque carte par type (legacyDeptColor partagé) — bordure gauche

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:50:21 -04:00
louispaulb
8b23367939 docs: cadre best-practices « ne rien échapper » + automatisation closed-loop (tailored stack TARGO)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:34:10 -04:00
louispaulb
4b377eb887 feat(dispatch): bouton « Activer STB (Ministra) » — lien d'activation TV extrait du ticket legacy (Phase 1)
- pont : extrait le lien connect_ministra.php du ticket_msg (déjà posté par le wizard legacy à la vente)
  → champ legacy_activation_url sur le Dispatch Job (backfill des 10 tickets TV). Read-only legacy, aucun 2e
  chemin d'écriture → zéro risque de double-facturation (cf. analyse processus).
- store _mapJob : expose legacyActivationUrl ; RightPanel : bouton violet « 📺 Activer STB (Ministra) »
  (MÊME lien que le tech reçoit, ouvre connect_ministra.php avec tid/did/sid/cr/m intacts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:20:09 -04:00
louispaulb
15976342e4 feat(dispatch): bouton « Répondre dans legacy » (lien reply_ticket.php du tech)
- useHelpers.legacyReplyUrl(job, staffId?) → https://store.targo.ca/targo/reply_ticket.php?ticket=<legacy_ticket_id>&staff=<3301 par défaut = Tech Targo>
- RightPanel : n° ticket legacy cliquable + bouton d'action « 📝 Répondre dans legacy »
- permet au tech d'écrire dans le ticket legacy depuis ERPNext (lecture seule de notre côté)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:30:32 -04:00
louispaulb
67395cd35e feat(dispatch): pastille couleur par type + badge « en retard » dans le pool & le détail
- BottomPanel : pastille couleur (jobColor → type legacy) par ligne + badge  EN RETARD
  sur les groupes de date passée (le pool est déjà groupé/trié par date)
- RightPanel : badge « en retard » près de la date planifiée (hors Completed)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:19:03 -04:00
louispaulb
dadda9fd49 feat(dispatch): coloriage cartes par type legacy + n° ticket dans le détail
- pont : stocke legacy_dept (dept osTicket) sur le Dispatch Job + backfill des 70 existants
- useHelpers.jobColor/legacyDeptColor : palette comme legacy (Installation vert, Réparation or,
  Télé rose, Téléphonie vert pâle, Désinstallation rouge foncé) ; tech en pause = rouge vif prioritaire
- store _mapJob : expose legacyDept + legacyTicketId
- RightPanel : champ « Ticket legacy » (#id · dept) — le client est déjà un lien cliquable vers la fiche
- doc mise à jour

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:05:45 -04:00
louispaulb
70bf25ea84 feat(dispatch): pont legacy(osTicket)→Dispatch Job pour les tickets « Tech Targo »
Tire régulièrement les tickets ouverts assignés au compte « Tech Targo » (staff 3301)
de la DB legacy MariaDB et crée/maj un Dispatch Job ERPNext (pool à répartir).

- lib/legacy-dispatch-sync.js : fetch (status=open AND assign_to=3301) + mapping
  customer (legacy_account_id) / Service Location (coords) / job_type (dept) /
  scheduled_date (epoch→America/Toronto) / start_time (am|pm|HH:MM) / priority
- Idempotent via Custom Field Dispatch Job.legacy_ticket_id (lookup avant create) ;
  ne clobbe pas le travail du répartiteur (maj date seulement si encore open+non assigné)
- SÉQUENTIEL (frappe_pg) ; endpoints GET preview (dry-run) + POST run
- Récurrence opt-in : startSync() au boot, LEGACY_DISPATCH_SYNC=on + _MIN=15
- server.js : route /dispatch/legacy-sync + startSync()
- doc docs/features/legacy-dispatch-bridge.md + index

Mise en service : 70 tickets importés (0 erreur), récurrence 15 min active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:33:53 -04:00
louispaulb
c4de33d448 roster(planif): chip compteur « N hors quart » dans la barre (signal hebdo des jobs assignés sans quart publié)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:15:16 -04:00
louispaulb
bc5bb06794 roster(planif/dispatch): On-Hold bloqué, alerte hors-quart, deep-link Dispatch, aviser client (#58)
- On Hold : onCellDrop REFUSE d'assigner un job en attente d'un prérequis (notify), reste au panneau (≠ 🔒 visuel)
- Hors quart publié : marqueur ⚠ dans la cellule libre (offShiftJobs/rawCellJobs lit occByTechDay brut) +
  badge « hors quart » dans la timeline ressource — surface les jobs assignés un jour sans quart
- Deep-link : Planif gotoDispatch(tech) → /dispatch?tech=&date= ; DispatchPage lit route.query
  (goToDay(date+T12:00:00) anti-décalage tz + selectTechOnBoard)
- #58 : bouton « Désaffecter + aviser le client » dans le dialogue d'unassign Dispatch →
  roster.notifyReschedule (désassigne serveur + SMS lien /book au mobile du Customer)
- Doc docs/features/roster.md mise à jour (Fait récemment / TODO)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:13:17 -04:00
louispaulb
f1204ed459 roster(planif): assignation drag-drop + timeline ressource + occupation + nettoyage
Panneau « jobs à assigner » v2 : multi-sélection (cases), groupes parent-enfant
surlignés, heuristique terrain/à distance (activation/netadmin pré-décochés),
pré-total d'heures, aperçu d'occupation PROJETÉE au survol (barre fantôme + badge).

Fix barre d'occupation figée après drop : /roster/assign-job pose désormais un
start_time (premier-trou-libre dans le shift) + garantit duration_h, sinon le job
compte dans les heures mais n'affiche aucun bloc. Nouvel endpoint
/roster/backfill-start-times (idempotent) pour rattraper l'historique.

Infobulle de cellule : nb de jobs + liste triée par priorité (occupancyByTechDay
renvoie jobs[]). Timeline contextuelle par ressource (dialogue, 0 appel réseau).

Lisibilité du drag : fantôme compact semi-transparent décalé sous le curseur
(ne masque plus l'aperçu) + source estompée.

Scoring de priorité : hook proximité (neutre — secteur géré manuellement),
réservé à 20% du score quand la géoloc arrivera.

Refactor hub : helper partagé firstFitStart (assign-job + backfill).
Nettoyage : retrait code mort (onDeleteRosterTag, projUsedH), carte des sections
en tête de PlanificationPage. Doc : docs/features/roster.md + index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:50:17 -04:00
louispaulb
70c89b2cea Garde: 2 horaires par règle — semaine (soir 17h-minuit) vs fin de semaine (8h-minuit)
Une règle a maintenant un shift SEMAINE + un shift FIN DE SEMAINE (optionnel). La rotation reste
UNE séquence (même tech sur la semaine) ; la génération choisit l'horaire selon le jour : weekend
(sam/dim) → shiftWeekend, sinon shift semaine. Wipe couvre les 2 shifts. Modèles semés:
« Garde soir 17h-00h » (17:00-23:59) + « Garde fin de sem. 8h-00h » (08:00-23:59), on_call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:33:07 -04:00
louispaulb
0320c8716f Garde: fix « 0 assignations » — rétrocompat des règles sans steps/anchor
Les règles créées avant le refactor (techs[]+periodWeeks, sans steps/anchor) donnaient 0 assignation
(rotationTech voyait 0 étape) + anchor manquant → toujours step 0. Fix: ruleSteps() convertit techs[]+
periodWeeks en étapes {tech,weeks} (collapse des doublons consécutifs) et ruleAnchor() retombe sur une
référence stable (epoch). rotationTech/gardeSeqLabel/editGardeRule passent par ces helpers → les vieilles
règles génèrent + s'affichent correctement, sans devoir tout recréer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:29:57 -04:00
louispaulb
e454e3f276 Garde: moteur fiable — séquence d'étapes {tech, weeks} + semaine d'ancrage (parcours déterministe)
Refonte du principe de rotation suite aux bugs (3 sem. au lieu de 2, édition non reflétée) :
- Séquence = étapes {tech, nb de semaines} ; « 2 semaines consécutives » = weeks:2 (fini les
  doublons+periodWeeks qui se multipliaient). Tours inégaux = weeks différents par étape.
- ANCRAGE explicite (semaine de départ, donnée de référence stockée) : on PARCOURT la séquence
  semaine par semaine depuis l'ancrage → déterministe, reflète les éditions, pas de dérive.
- Vérifié: A×2,B,C ancré 8 juin → A,A,B,C,A,A,B,C (A toujours 2 consécutives) ; réordonner reflète.
- Aperçu et génération utilisent le même parcours. Migration auto des anciennes règles (techs+period→steps).
  Rappel: après édition, re-cliquer « Générer la garde » (l'horizon est réécrit, wipe ciblé du shift).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:15:50 -04:00
louispaulb
761498d65c Planification: marquer une absence d'1 jour depuis la grille (touche A + menu)
Clic sur une case → touche « A » (bascule) ou menu « Marquer absent ce jour / Retirer l'absence » →
crée/supprime une Tech Availability d'1 jour APPROUVÉE (hachurée tout de suite). Multi-sélection
supportée (A marque toutes, re-A retire). Endpoint POST /roster/absence/set {tech,date,type,remove}
(remove ne touche que les absences d'un jour, pas les vacances multi-jours). Type défaut Congé.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:02:39 -04:00
louispaulb
58253d2e2f Garde: génération sur un HORIZON multi-semaines (évènement récurrent) au lieu d'une seule semaine
Avant: « Appliquer à la semaine » n'écrivait qu'une semaine → 1 seul tech (rotation hebdo). Maintenant
« Générer la garde » matérialise la rotation sur N semaines (4/8/12/26) d'un coup, écrit direct (publié),
navigable semaine par semaine — comme un évènement récurrent. Endpoint /roster/garde/apply : wipe ciblé
des shifts de garde dans l'horizon + recréation (idempotent, reflète l'édition de la séquence). Saut
d'absent conservé. (source='manuel' car le Select n'autorise que solveur/manuel.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:58:15 -04:00
louispaulb
2b71d1c78c Garde: presets Soirs de semaine/Fin de semaine (combinables) + rotation par semaine (défaut) + aperçu
- Plages combinables: « Soirs de semaine » (L-V) + « Fin de semaine » (S-D) en toggles (+ chips fins).
- Rotation par défaut = PAR SEMAINE (garde = bloc hebdo) → corrige la séquence brisée (par jour, un
  week-end pouvait avoir 2 personnes). Index de semaine continu dans le temps → ordre respecté en
  naviguant. Séquence [A,A,B] = A 2 semaines consécutives, puis B. (Sélecteur jour/semaine conservé.)
- APERÇU live : qui est de garde sur les 8 prochaines semaines, reflète la file en cours d'édition →
  on voit l'ordre respecté + l'effet des modifs avant d'appliquer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:47:04 -04:00
louispaulb
b9d4d46d1c Roster publish: diff au lieu de wipe+recreate → publication quasi instantanée
Avant: /roster/publish-week supprimait TOUTES les assignations de la semaine puis les recréait toutes
(~2N écritures ERPNext séquentielles → plusieurs secondes/dizaines de s). Maintenant: diff par clé
tech|date|shift — supprime seulement les retirées, crée seulement les nouvelles, ignore les inchangées.
Republier sans changement: 87 assignations → 0 écriture, 37 ms (vs ~174 écritures avant). Une modif
de quelques cellules = quelques écritures seulement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:20:03 -04:00
louispaulb
16325ed967 Garde: la rotation avance par jour de garde (la séquence continue sur les jours affichés)
Avant: rotation par semaine → dans une vue d'1 semaine, un seul membre apparaissait. Maintenant la
rotation avance par OCCURRENCE de garde (occurrenceIndex = nb de jours matching weekdays depuis l'époque)
→ chaque jour de garde prend le membre suivant de la séquence (A,A,B,C…), visible sur toute la vue.
Sélecteur « Rotation : par jour de garde / par semaine » (+ N consécutifs). Défaut = par jour ;
les règles existantes (sans unit) basculent en par-jour. Saut d'absent + doublons conservés.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:17:25 -04:00
louispaulb
d0ab57b1b5 Garde: séquence de rotation éditable — doublons permis + remplacer un tech par position
Le multi-select est remplacé par un constructeur de SUITE ordonnée : « Ajouter un tech à la suite »
(push, doublons autorisés → tours inégaux ex. A,A,B,C) ; chaque position a un select pour REMPLACER
le tech, + ↑/↓ pour réordonner, + ✕ pour retirer. Couvre rotations inégales et substitutions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:01:42 -04:00
louispaulb
05b5b16a5d Garde: dept libre + liste techs complète + réordonner la rotation + éditer + 2 sem. consécutives
Fix listes vides: département = champ libre optionnel (existants OU texte), liste des techs = TOUS
(plus de désactivation sur dept). Réordonnancement de la rotation (↑/↓), édition d'une règle (crayon
→ recharge dans le formulaire → Mettre à jour). Champ « Sem. consécutives / tech » (mettre 2 = un tech
fait 2 semaines de suite). Annuler l'édition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:53:26 -04:00
louispaulb
fe60eeb485 Planification: drapeau « longue durée » sur absence (maternité/invalidité = à remplacer)
Tech Availability gagne long_term (Check). Case à cocher « Longue durée » dans le dialogue Congés.
absencesByTechDay encode « (longue durée) » → applyTemplate force « à remplacer » (pas juste sauter
comme des vacances), même si l'absence ne couvre pas toute la semaine. Complète l'intelligence
permanent vs vacances (avant: déduit de « absent toute la semaine »).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:31:39 -04:00
louispaulb
8d946daf8d Planification: rotation de garde par département (récurrence + rotation)
Dialogue « Garde » : règles par département (tech_group) = {shift de garde, jours, période (toutes
les X sem.), techs en rotation ordonnés}. Indépendantes entre départements (non synchronisées).
« Appliquer à la semaine » génère les gardes : pour chaque jour ciblé, le tech de garde = rotation
(index de semaine / période % liste) ; un tech absent est SAUTÉ au profit du suivant. Règles persistées
(localStorage roster-garde-rules-v1). Les gardes s'affichent en pointillé ambre (on_call), hors heures
travaillées/booking déjà en place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:27:24 -04:00
louispaulb
060ee578c3 Planification: modèle par défaut (★) appliqué en 1 clic
Marquer un modèle de semaine comme défaut (★ dans le menu Modèles, un seul à la fois) →
bouton ★ <nom> dans la barre pour l'appliquer en 1 clic (avec l'intelligence d'absences déjà en place).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:23:40 -04:00
louispaulb
cdd72856a4 Planification: application de modèle consciente des absences (permanent vs vacances)
applyTemplate respecte maintenant les absences : on n'assigne pas un tech absent ce jour-là.
- Absent toute la semaine (≈ congé permanent: maternité/blessure) → signalé « à remplacer » (warning).
- Absent quelques jours (≈ vacances) → ces jours sautés, le reste du patron tient.
Le toast résume: N assignations, absences partielles ignorées, et qui est à remplacer (avec le type).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:16:05 -04:00
louispaulb
9261692c7f Planification: menu de case court (2 raccourcis + slider en haut) — Appliquer toujours atteignable
- Menu réduit: 2 raccourcis (Normal 8–17, Soir 16–20) + slider d'ajustement remontés EN HAUT
  (près du clic) ; Appliquer dans la rangée du slider → reste visible même si la case est en bas
  (max-height 85vh + flip auto Quasar). Retrait de la longue liste des modèles.
- quickShift(min,max) + applyWindow() factorisés. Shifts en place + Copier/Coller/Vider en icônes
  compactes sous le slider. Nettoyage cellCode/cellColor/codeByShift/colorByShift/addFromMenu inutilisés.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:55:32 -04:00
louispaulb
ece0ccb6ff Planification: Suppr/Backspace pour vider les cases sélectionnées (ou la case active)
onKey: garde anti-champ (n'intercepte pas dans input/textarea/select/contenteditable) + empêche
le 'retour arrière' du navigateur (preventDefault). Suppr/⌫ vide la sélection (ou la case cliquée),
avec pushHistory (annulable). Hint de légende mis à jour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:46:04 -04:00
louispaulb
89a366d197 Planification: hachuré = ABSENT (congé/pause), garde = pointillé ambre (sur appel hors heures)
- Hachuré gris = ABSENT (Tech Availability approuvée + En pause). Nouvel endpoint /roster/absences
  (En pause global + congés approuvés par jour) → la cellule d'un tech absent est hachurée (tooltip = type).
  Remplace l'ancien 'P' pause.
- GARDE = nouveau visuel: bande à CONTOUR POINTILLÉ AMBRE + fond ambre léger (sur appel, hors heures
  d'ouverture) — distinct du travail planifié et de l'absent.
- Légende: dispo (matin→soir) · occupation · absent (hachuré) · garde (pointillé). Retrait 'P pause'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:43:47 -04:00
louispaulb
021417f29f Planification: contour 1px foncé autour de la bande de disponibilité (vs fond du timeline)
La bande pâle se confondait avec le fond gris du timeline → contour 1px bleu-violet foncé
(rgba 55,65,120,.5) box-sizing border-box pour ne pas déborder. Garde = contour brun.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:36:07 -04:00
louispaulb
5ad17c0d19 Planification: fix slider d'ajustement (menu) — sélection de texte + resize continu
- user-select:none sur le menu (rendu en portail → n'héritait pas du no-select de la grille)
  → le glissement ne sélectionne plus le texte.
- Retrait des bulles de label de la q-range (8h/18:30h) qui changeaient de largeur → le menu ne
  se redimensionne plus. La valeur reste affichée en direct sous le slider (8h–18:30h).
- Largeur du menu fixée (260px) + @mousedown.stop sur le slider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:29:57 -04:00
louispaulb
94c7566dd3 Planification: barre de temps pâle + barre de statut opaque vert→orange (occupation)
- Barre de TEMPS (dispo) = pâle (bleu très pâle matin → violet pâle soir) : repère discret du quand.
- Barre de STATUT (occupé) = OPAQUE vert (peu) → orange (plein) → rouge (surbooké), positionnée
  aux vraies heures des jobs → ressort nettement sur le fond pâle ; les trous pâles = offrables.
- Légende: dispo (matin→soir) pâle + occupation (vert→orange) + garde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:22:05 -04:00
louispaulb
d7867e2a62 Planification: cellule = barre de dispo seule sur une échelle de temps (règle horaire en-tête)
- En-tête de chaque jour: règle horaire (graduations alignées sur l'axe adaptatif, ex 8/12/16/20).
- Cellule réduite à LA barre de dispo (dégradé matin→soir, occupé assombri, garde hachuré) —
  plus de texte d'intervalle : on lit la position contre la règle, détails au survol.
- Barre un peu plus haute (11px). Retrait cellLabel/cell-int/cell-chips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:18:38 -04:00
louispaulb
738d315785 Planification: barre de dispo dégradée par l'heure (bleu matin → violet soir), fini les lettres
- Cellules: plus de chip-lettre (J/S/M/D). La bande de la timeline est colorée par l'HEURE :
  dégradé bleu pâle le matin → violet le soir (hsl 210→270). On lit 'quand' d'un coup d'œil.
- Occupé = assombrit la bande (clair = libre/offrable, foncé = pris) ; rouge si surbooké.
  (Remplace le feu vert/orange/rouge — plus sobre.) Garde reste hachurée.
- Légende: échantillon dégradé « matin → soir » + « garde » (hachuré) au lieu de la liste de lettres.
- Intervalle (8–16) gardé en texte. occColor retiré.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:12:27 -04:00
louispaulb
142ce45755 Planification: ne montrer que les presets nommés + coller multi-cases fiable + fix Cmd+C/V
- Nettoyage des modèles auto en double fait côté données (8h–16h→Jour, 7h–15h→Matinal,
  9h–17h→Décalé, 8h–17h→Jour, 8h–22h→Soir) — restent 5 presets propres.
- Barre d'assignation + légende = presetTemplates (modèles NOMMÉS seulement) → restent propres
  même si des modèles auto réapparaissent.
- Fix copier-coller : le clic ouvrait le menu ET vidait la sélection → Cmd+C voyait 0 cellule.
  Maintenant on mémorise activeCell (dernière case cliquée) ; Cmd+C/V ferment le menu et marchent
  sans multi-sélection. Coller = vers la sélection si multi, sinon la case active.
- Indicateur « N copié(s) » visible. Coller multi-cases via la barre (déjà) + Cmd+V sur sélection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:04:37 -04:00
louispaulb
7f6d314cc0 Planification: fix menu (régression cellHours) + copier/coller + slider d'ajustement dans le menu
BUG: le menu de case plantait sur les cases occupées (cellHours retiré mais encore appelé l.259)
→ c'est ce qui cassait le copier-coller au clic. Corrigé ({{ a.hours }}h).

- Copier/Coller déplacés dans le MENU de la case (clic simple, plus besoin de Cmd+clic) :
  « Copier cette case » / « Coller ». (Boutons barre + Ctrl/Cmd+C/V conservés.)
- « Ajuster l'horaire (glisser) » : q-range dans le menu → élargir/réduire le créneau de dispo,
  + toggle Garde. Applique = trouve/crée un modèle auto-nommé (8h–18h) et remplace la case.
  → la dispo élargie est aussitôt offerte au booking (modèle standard).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:24:07 -04:00
louispaulb
c2f3e4d666 Planification: copier-coller de cases + créneaux custom (slider q-range) + auto-nommage
- Copier-coller pour bâtir l'horaire vite : sélectionne une case → Copier (ses shifts) →
  sélectionne des cibles → Coller (duplique). Boutons dans la barre + raccourcis Ctrl+C / Ctrl+V.
  Copier une case vide puis Coller = vider les cases.
- Créneaux CUSTOM : nouveau modèle créé via slider q-range (2 poignées, pas 0.5 h) → plus besoin
  de prévoir tous les types. Nom AUTO si vide (« 8h–17h » d'après les heures).
- Presets standards semés : 7h–15h, 9h–17h, 8h–17h (+ Jour 8h-16h existant) — triés par usage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:02:15 -04:00
louispaulb
dfefd7822f Planification: cellule sans icônes — juste intervalle + timeline
Retrait des icônes en cellule (☀/🌆/🌙/🛡️). Le libellé = uniquement l'intervalle début–fin
(ex 8–16) ; le timeline (bande pleine vs garde hachurée) porte le reste visuellement.
Tag garde dans l'infobulle: '(garde)' au lieu de l'emoji.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:55:02 -04:00
louispaulb
17d8442b98 Roster: la garde ne compte PAS comme heures travaillées (mise en dispo)
Garde (on_call) exclue partout des heures travaillées + du coût:
- hub statsByDay: heures = somme des shifts NON-garde ; nouveau compteur on_call/jour (techs en dispo).
- Ops: hoursOf (heures/tech + alerte heures supp) et costByDate/weekCost excluent la garde.
- nouvelle ligne de pied '🛡️ Garde' = nb de techs en disponibilité/jour (si applicable).
Cohérent avec l'occupation (déjà hors-garde) : la garde = réserve d'urgence, ni offerte ni facturée.
Vérifié 8 juin: 112 h travaillées (garde 6 h exclue), garde=1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:52:01 -04:00
louispaulb
72845e2057 Planification: axe timeline adaptatif + intervalle texte + modèles triés par usage
#1 axe trop large (0-24) → axe ADAPTATIF calé sur l'amplitude réelle des shifts réguliers de la
semaine (garde n'élargit pas) → barres plus larges, position lisible. Intervalle début–fin REMIS
en texte dans la cellule (☀ 8–16 🛡️) = référence sans survol. Infobulle = capacité offrable
(corrige aussi un bug: shiftH→bookableH).
#2 modèles d'assignation TRIÉS PAR USAGE (les plus utilisés en premier) + infobulle nom.
(Rappel: créer des modèles custom = éditeur « Types de shift ».)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:48:47 -04:00
louispaulb
1ab9f64b48 Roster: quart de Garde (on_call) = réserve d'urgence, jamais offert au booking
Modèle: champ on_call (Check) sur Shift Template. Un quart garde:
- N'est JAMAIS offert au booking client (techGaps retourne null) — vérifié: tech Jour+Garde
  n'offre que la fenêtre Jour, aucun créneau dans la plage de garde.
- Est EXCLU du dénominateur d'occupation (heures offrables), affiché à part.
- Timeline: bande HACHURÉE (vs neutre pour l'offrable) + 🛡️ dans le label + tag (garde) en infobulle.
- Éditeur de modèles: bascule '🛡️ Garde' pour créer/marquer un quart de garde.
hub: fetchTemplates expose on_call; create/update template le gèrent. Champ ajouté à
setup_dispatch_custom_fields.py (persistance). Démo: Garde 18h-minuit marquée on_call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:37:34 -04:00
louispaulb
049897e021 Planification: micro-timeline 24h, multi-shift, neutre/coloré, label h utilisées + icône période
Refonte selon retour: axe 24h (00→24). Chaque shift = bande NEUTRE positionnée (multi-shift OK:
Jour + Garde affichés en 2 bandes, gère le passage minuit). Jobs pris = traits COLORÉS (charge:
vert/orange/rouge). Label compact = icône période (☀/🌆/🌙) + heures utilisées/total (ex 4/8).
Intervalles exacts par shift dans l'infobulle. Tick à midi (50%).

Démo 8 juin: TECH-4738 = Jour 8-16 + Garde 18h-minuit (multi-shift), Matinal 7-15 / Décalé 9-17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:27:32 -04:00
louispaulb
341c8e5a64 Planification: mini-timeline positionnée (fenêtre réelle du shift + blocs pris)
Avant: 'J8' ne distinguait pas 7-15 de 9-17 → mêmes créneaux apparents, dispo réelle différente.
Maintenant chaque cellule affiche: chip (lettre) + intervalle '7–15', et une mini-timeline sur un
axe de journée (06:00→21:00) où la fenêtre du shift est positionnée (donc 7-15 à gauche, 9-17 à
droite = visuellement distinctes) avec les blocs de jobs pris (couleur selon charge) → les TROUS
restants = créneaux offrables. Infobulle = intervalle + h occupées/h (%).

- hub occupancyByTechDay renvoie {h, blocks:[{s,e}]} (heures de début réelles des jobs).
- ops: cellWindow/axisPos/shiftStyle/blockStyle, rendu .tl/.tl-shift/.tl-blk + tick midi.
- démo 8 juin: modèles Matinal 7-15 + Décalé 9-17, techs alignés (7→13.8, 9→18.6 surbooké).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:21:04 -04:00
louispaulb
49795f858b Planification: taux d'occupation par cellule (jobs assignés / heures du shift)
Chaque cellule tech×jour avec un shift affiche, sous le chip (J8), une mini-barre + % colorés
(vert <70, orange 70-99, rouge >=100 surbooké) + infobulle = intervalle du shift + h occupées/h.
Occupation = Σ duration_h des Dispatch Jobs planifiés assignés ce jour ÷ Σ heures du shift.

- hub: occupancyByTechDay(start,days) + GET /roster/occupancy → map 'TECH|date': heures.
- ops api: getOccupancy ; PlanificationPage: occCells (computed), cellOcc/occColor/cellInterval,
  rendu barre + q-tooltip, chargé dans loadStats. Données démo semaine 8 juin (45/85/120%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:13:27 -04:00
louispaulb
88b2702489 Copilote: agit vraiment sur les absences (gerer_absence + IROPS rematch) + gig dispo
Bug: le copilote comprenait l'absence mais n'agissait pas → aucun impact. Causes:
1) prompt « par défaut analyse » → ne déclenchait pas l'action ;
2) marquer une absence n'excluait PAS des créneaux (techGaps ne testait que le statut
   global En pause, pas les Tech Availability approuvées par jour) ;
3) loadBookingData calculait unavail mais ne le retournait pas (oubli) → garde inerte.

Fixes:
- roster.js: loadBookingData inclut unavail (buildUnavailability = En pause + absence_from/until
  + Tech Availability approuvées) ; techGaps exclut le tech absent ce jour-là ; export bookingSlots/fetchTemplates.
- roster-assistant.js: nouvel outil gerer_absence = crée l'absence (par jour) + trouve les RDV
  impactés + RÉASSIGNE auto à un autre tech libre du même créneau (IROPS), renvoie les
  « à reporter ». Nouvel outil ajouter_disponibilite (tech à l'acte ouvre un créneau). Prompt
  orienté ACTION (signaler une absence = instruction d'exécution).
- Validé prod (lab): copilote crée l'absence ✓, booking exclut le tech absent (109→104) ✓,
  rematch DJ-…→Antoine même créneau ✓ ; données de test nettoyées.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:43:03 -04:00
louispaulb
43c67e3a18 Ops RDV+Copilote: vue agent (semaine/jour + hold), file À recontacter, réglages #56
RendezVousPage:
- Vue segmentée À planifier / À recontacter / Tous.
- Créneaux proposés groupés Semaine → Jour (se situer dans le temps, comme /book).
- Hold à la sélection: bookHold(date,start,10min) → bloque les autres; libéré à la confirmation
  ou au changement de job (onBeforeUnmount).
- File À recontacter (jobs À reporter) + actions: Lien client (copie URL self-serve),
  Aviser par SMS (notify-reschedule: désassigne + SMS lien /book).

CopilotePage: carte réglages des créneaux offerts (#56) — lead_hours, plage horaire,
horizon, max/jour, hold, jours offerts (chips) → savePolicy({booking}).

api/roster.js: bookHold, bookLink, jobsToReschedule, notifyReschedule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:28:14 -04:00
louispaulb
7f3ad56188 Booking #56: politique de créneaux offerts + holds temporaires
- getBookingPolicy() (sous-objet 'booking' du fichier policy): lead_hours, day_start/end,
  days_offered, horizon_days, max_per_day, hold_minutes. Appliquée dans bookingSlots →
  cohérent pour /book + vue agent + fit. ignorePolicy au moment de confirmer (slot encore libre).
- Holds en mémoire (Map TTL): /roster/book/hold {date,start,minutes|release} → fenêtre retirée
  des dispos des autres pendant le hold; libérée à la confirmation. Évite le double-booking
  pendant qu'un agent/client choisit.
- roster-assistant: DEFAULT_POLICY.booking + booking_fields/weekdays (descripteurs UI), fusion fine.
- Testé: plage 10-14h filtre bien; hold 10:00 dispo 12→11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:19:42 -04:00
louispaulb
69ad35b9bc Booking /book: grille semaine → jour → Matin/Après-midi (se situer dans le temps)
Avant: liste plate jj/mm peu lisible. Maintenant: bandeau de semaine ('Semaine du 8 – 14 juin'),
en-têtes de jour complets (lundi 8 juin), sections Matin/Après-midi. Sélection 1-3 préférences inchangée.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:04:08 -04:00
louispaulb
42c07d36f2 Fix reschedule: notify-reschedule désassigne (vide date/heure/tech, status open) → /book repropose des créneaux
Avant: le job gardait scheduled_date → page /book court-circuitait en 'déjà confirmé' sans options.
Maintenant un report = vrai retour au pool → le client choisit un nouveau créneau (vérifié: 50 créneaux proposés, SMS livré).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:56:20 -04:00
louispaulb
097e0566ec Reschedule: endpoint aviser-client (lien /book + SMS Twilio + statut 'À reporter') + file 'À reporter' + outil copilote
/roster/job/notify-reschedule (job,phone?) → token /book + SMS + booking_status='À reporter'.
/roster/jobs-to-reschedule → file superviseur. Copilote: outil notifier_client_report.
Testé OK (chemin sans téléphone = sûr). Brique P5 de #55, déclenchable depuis le flux de pause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:28:45 -04:00
louispaulb
5d371a2a8b Ops: authFetch robuste — reconnexion auto sur session Authentik expirée
Avant: ne rechargeait que sur 401/403 stricts → ratait la redirection IdP / page HTML de
login (vrai cas d'expiration) → données vides nécessitant un refresh manuel. Maintenant:
détecte redirection auth.targo.ca + HTML-au-lieu-de-JSON → reload auto (anti-boucle 20s),
+ 1 retry sur coupure réseau transitoire (ex: backend qui redémarre).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:20:01 -04:00
louispaulb
1f47ee4eae Ops cohérence: composants partagés TechSelect (autosuggest) + SkillSelect (chips)
Corrige les manques signalés: champ technicien (congés) → autosuggest typeahead; compétences
(demande de shift + éditeur équipe) → chips au lieu de texte libre. Composants réutilisables
pour une UX cohérente partout (et le copilote/réassignation à venir).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:57:58 -04:00
louispaulb
412b6f49a6 Copilote roster: outils d'action (marquer_indisponibilite + reassigner_job)
Le copilote peut désormais CHANGER les dispos en langage naturel ('marque Simon malade
aujourd'hui' → crée une Tech Availability approuvée) et réassigner un Dispatch Job, sur
instruction explicite, avec confirmation. Testé OK end-to-end. (SMS client + escalade = suite.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:57:33 -04:00
louispaulb
79d160b9f1 Ops: page Copilote dispatch (chat + voix + sélecteur de politique)
Nouvelle page /copilote : chat texte/voix (Web Speech API fr-CA) vers le copilote Gemini Flash
(impact d'absence + propositions de réassignation), + sélecteur de politique de reprise
(réassign/SMS/escalade) persistée. Route + nav (icône Sparkles ; ajout CalendarRange/CalendarClock
manquantes dans la map d'icônes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:09:16 -04:00
louispaulb
d1bd268a32 Copilote roster (Gemini Flash, function-calling) + politique de reprise configurable
lib/roster-assistant.js : couche conversationnelle sur le roster (distincte du solveur OR-Tools).
Outils data réels (etat_equipe, jobs_du_technicien) via roster.fetchTechnicians + Dispatch Job.
Ex: 'TECH-4776 malade le 16 juin' → résout le nom, liste les RDV impactés, propose des techs
dispos qualifiés. Routes /roster/assistant + /roster/policy (politique persistée fichier).
Réutilise le moteur geminiChat de lib/agent.js (gemini-2.5-flash). Testé OK avec données réelles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:08:26 -04:00
louispaulb
3a90dafb9f docs: déploiement Karrio (expédition multi-transporteurs self-hosted) + sources
Architecture, accès, Dockerfile.carriers + override compose (sanitisés), transporteurs
(Canada Post/Purolator/Canpar/Nationex/Dicom/BoxKnight + agrégateurs eShipper/Freightcom),
pièges (collision hostname, admin, binaire venv), plan d'intégration hub #54. Sources incluses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:25:36 -04:00
louispaulb
a855e11476 Store: prix barré par produit via champ Item.store_regular_price
Affiché barré (gris) si > prix de vente (Item Price), sur produits simples + variantes
(les bundles gardent barré=somme des composants). Catalogue lit le champ; page rend <s>.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:55:57 -04:00
louispaulb
5807d58913 Store: catalogue live depuis ERPNext (GET /store/catalog)
- Curation = champ Item.show_in_store (case à cocher) ; catégorie=item_group, prix=Item Price, stock=Bin live
- Bundles via Product Bundle (prix vs somme barrée), variantes mono-attribut (delta prix + stock/valeur)
- Fix filtres erp.list (tableaux, pas objets — listUrl teste .length) + child-tables lues via erp.get parent
- Page: fetch /store/catalog au mount, repli démo, bandeau live/démo, stock simple respecté

Prérequis PG corrigé: retrait ORDER BY NULL (MySQLism) dans erpnext utilities/product.py
(validation de variante) — sinon création de variantes impossible sur PostgreSQL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:23:01 -04:00
louispaulb
87949f933d Boutique matériel — page modèle /store (staging, self-contained Vue 3)
Vitrine fluide inspirée des meilleurs carts : grille produits, visualisation de
variantes (swatches couleur + pills longueur/modèle, prix delta live, rupture barrée),
panier optimistic (drawer slide, steppers, badge animé, toast, taxes QC, total).
Données démo (caméras/boosters/câbles/TP-Link/bundles). Branchement ERPNext (catalog
+ Product Bundle + pricing) = étape suivante. Validé live au navigateur.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:09:47 -04:00
louispaulb
37f4d5a941 Wizard: section Installation (palier financé) à l'étape Items
- Presets Standard 360→240 (10$/mois) / Simple 180→120 (5$/mois) / Aucune
- 2 cases éditables Prix original / Prix financé + aperçu live (barré, $/mois, crédité→0$)
- Alimente install_fee/install_regular du Service Contract → page d'acceptation affiche le détail
- Placé sous Code de référence dans la vue résidentielle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:20:04 -04:00
louispaulb
e2104c93f2 Wizard: 2 cases génériques Prix marketing / Prix original (barré) sur toutes les lignes + aperçu client WYSIWYG
- Prix original (regular_price) disponible sur lignes récurrentes ET ponctuelles (avant: ponctuelles seulement)
- Si Prix original > Prix marketing → barré affiché (sommaire + récap + aperçu live dans l'éditeur)
- Forfait récurrent barré remonte au contrat (monthly_regular) → page d'acceptation affiche <s>99.95</s> 79.95/mois
- Cohérent avec install (install_regular 360→240). Aucun impact CRTC (rabais go-forward, jamais récupéré)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:31:54 -04:00
louispaulb
4b2e6fb698 Install: prix barré 360$ → 240$ (ancre de valeur) + 'financement crédité chaque mois 24 mois' dans contrat + portail signup
Champ install_regular (Service Contract) = valeur affichée barrée; install_fee reste le montant financé/dû réel. Conforme CRTC: 360 = référence marketing seulement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:22:22 -04:00
louispaulb
7267a27cda Portail self-service abonnement (staging /signup): stepper épuré Forfait→Coordonnées→Récap→Confirmation, catalogue réel, capture Lead
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:16:19 -04:00
louispaulb
6ff4a324ca Récap contrat: détail mensuel (forfait + financement install − rabais nouveau client = net), install offerte tant qu'abonné
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:08:08 -04:00
louispaulb
45c31e555f Contrat résidentiel: conformité CRTC 2026-43 (fin du clawback)
Installation = vraie créance financée (champ install_fee : 240$ std → 10$/mois,
120$ simple → 5$/mois). Résiliation résidentielle ne facture que le solde restant
de l'install (service rendu) ; promotions/carte-cadeau jamais récupérées
(termination_fee_benefits=0). Récap reformulé (financement install, plus de
"promotion non étalée"). Conforme avant l'échéance du 12 juin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:05:56 -04:00
louispaulb
ba76b8f3cc Site: texte Actualités révisé + template courriel gift natif (FR)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:42:52 -04:00
louispaulb
f4138cdd75 Roster AI (planification) + prise de rendez-vous client
Solveur OR-Tools (services/roster-solver) : couverture, compétences,
équité, coût chargé, cadence/efficacité, capacité-par-job ; contraintes
dures/souples façon Timefold.

Hub (lib/roster.js) : génération via solveur, publication par réécriture
de semaine (anti-doublons), demande (effectif ou nb de jobs), cadence/coût/
compétences par tech, pause, congés (Tech Availability + approbation),
booking (slots roster-aware / fit 3-dispos / confirm) + portail public /book.
Réessai sur serialization failures frappe_pg ; appels ERP séquentiels.

Ops : page Planification (grille compacte « J8 », multi-shift, drag-select
+ undo/redo, modèles de semaine, éditeur cadence&coût, congés, SMS opt-in),
page Rendez-vous (répartiteur), jobColor tech en pause → tickets rouges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:42:44 -04:00
louispaulb
d8366ab0be docs: spec module Shifts (intégré dispatch, sans paie)
Spec v1 du module de gestion de shifts + approbations dans le dispatch
(décision build-our-own vs Frappe HR déjà actée: PG + multitenant + géofence-job).
Modèle de données, workflows d'approbation (ShiftRequest/Swap/AttendanceRequest),
géofence vs adresse du job dispatch, auto-attendance, auto-roster Timefold,
mapping validé vs Frappe HR, intégrations (Authentik/dispatch/ERPNext API), phasage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:06:19 -04:00
louispaulb
21e2c846bf feat(ops): Email Queue admin page (view/delete/purge ERPNext outbound)
Surfaces ERPNext's Email Queue in Ops (nav « File courriels ») so ops can see
what's queued — important now that mute_emails=1 + scheduler paused mean nothing
flushes — and delete/purge stale entries without the ERPNext desk.

- hub lib/email-queue.js: GET list (by status, recipients read from each row's
  full doc since ERPNext ignores fields on child-doctype REST), DELETE :name,
  POST /purge {status}. Wired in server.js.
- ops: api/emailQueue.js + EmailQueuePage.vue (status filter, recipients,
  reference, error tooltip, per-row delete + « Purger Not Sent »), route + nav.

Verified live: 13 'Not Sent' (old internal test emails, no invoice refs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:29:33 -04:00
louispaulb
c4bf18fdcb fix(legacy-report): treat accounts with a company name as commercial
98 business accounts had commercial=0 but a company name (Ferme X Inc.,
Ville de Farnham, Les Jardins Sorel…), leaking into the residential report.
Rule is now: commercial = (commercial flag) OR (company present). Residential
@90$ drops 739→654 (0 company-bearing rows left); commercial 244→276.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 06:33:25 -04:00
louispaulb
b01cf19db6 chore(legacy): reusable targeted refresh of report tables from live billing DB
scripts/refresh-legacy-report-tables.sh: dumps only the 4 tables the
overpriced-internet report needs (account/delivery/service/product) from the
live legacy DB with --single-transaction (non-locking), verifies the dump is
complete before importing into the local legacy-db copy. ~20MB, seconds.
Creds read from a prod-only .refresh.env (gitignored; .env.example committed).

Used to refresh the copy from the 2026-05-22 snapshot to current
(service 66741→69233, account 15321→15706).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:03:21 -04:00
louispaulb
bde7a5ef67 feat(ops): Ville column in overpriced-internet report (sort + filter by city)
Adds a sortable 'Ville' column (field city) to the report. Quasar's default
filter scans all columns, so the existing search box now matches city too.
Street address caption drops the now-redundant city (keeps postal code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:44:34 -04:00
louispaulb
105b0b2a51 feat(ops): per-address competitor column via Québec IHV open data
Replaces the reCAPTCHA-blocked Cogeco scraper with the authoritative Québec
"Accès Internet haute vitesse" open ArcGIS data (providers declared to the
gov by the providers themselves — validated to match Cogeco's own popup).

- hub lib/serviceability.js: address → ADR (Adresse_complete → IdAdresse +
  Etat_hiv, civic+postal match w/ JS street disambiguation) → FRN table
  (IdAdresse → FRN_nom providers + signup URLs). Referer-gated proxy, disk
  cache (90d), polite rate limit. Routes /serviceability/lookup[-batch].
- ops ReportInternetCherPage: "Concurrence (FSI)" column — provider chips
  (Cogeco highlighted), batch-fetched on demand with progress; "Cogeco
  disponible" summary card = churn-risk count; manual Cogeco verify icon kept.

Validated live: 37 Chemin Noël → Cogeco+Targo, 147 Montée Richard → Targo
only, Repentigny → Bell+Cogeco. Endpoints documented in
memory/reference_quebec_ihv.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:40:47 -04:00
louispaulb
76573f58e9 fix(website): rewrite garbled "Notre histoire — 2005" section
The origin story had a duplicated/merged first sentence, an inconsistent
name (Ghislain / Ghislain Guinois / "Ghilas" typo), and a present-tense
verb ("travaille") inside an otherwise past-tense narrative. Rewrote the
three paragraphs into clean, consistent French (passé composé), fixed the
name to "Ghislain Guinois", "18h" → "18 h".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:15:33 -04:00
louispaulb
5bc42bda9f fix(cogeco-checker): disable browser cache to rule it out as 401 cause
Tested the hypothesis that a warm Chromium cache (the register GET being
re-served stale) was causing the protected address/search 401. Disabled
the HTTP cache (CDP Network.setCacheDisabled), the on-disk cache
(--disk-cache-size=0) and service workers (serviceWorkers:'block').

Result: identical trace — register=200 (freshly minted, not cached),
autocomplete=200, address/search=401. So cache was NOT the cause; the
401 is a server-side authorization decision on the protected endpoint
(reCAPTCHA Enterprise assertion required). Keeping the cache-disable as
hygiene + to definitively rule it out in future debugging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:47:19 -04:00
louispaulb
68ba64c47b feat(ops): assisted Cogeco spot-check on overpriced-internet report
Cogeco's address checker is gated by reCAPTCHA Enterprise (risk-score 401
on the protected /boutique/api/address/search call), so per-address
serviceability can't be scraped reliably from a datacenter IP without a
residential proxy. Per product decision, pivot to an assisted spot-check
instead of automated qualification.

- ReportInternetCherPage: add a "Concurrent" column with a one-click
  button that copies the full service address and opens Cogeco's
  availability page in a new tab (human reads the verdict in ~10s, only
  for the leads that matter). fullAddress() builds "addr, city, QC ZIP".
- cogeco-checker: harden the POC anyway — track service-address/search
  responses, retry the verdict call on 401 (re-register cadence), and
  prioritize the authoritative JSON body in interpret(). Recon confirmed
  the wall is reCAPTCHA scoring, not a timing/selector bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:24:36 -04:00
louispaulb
74b89f5490 feat(cogeco-checker): POC competitor-serviceability microservice (WIP)
Playwright/Chromium microservice (mirrors modem-bridge: node:20-slim +
Chromium, token auth, port 3302, serialized + rate-limited) that drives
Cogeco's public address checker to determine if a competitor serves a
given address.

What works (proven on prod):
- Anti-bot bypass: vanilla headless gets 403 on /boutique/api/register
  (reCAPTCHA Enterprise blocks datacenter headless). Adding
  playwright-extra + stealth flips it to 200 — register + autocomplete
  succeed.
- Reaches Cogeco's address system and pulls real autocomplete
  suggestions. Confirmed it's Loqate/AddressComplete (id + next:
  Retrieve/Find shape).

What's NOT reliable yet (do not use the verdict for decisions):
- The serviceability verdict. The Loqate flow is multi-step
  (Find → Retrieve → Cogeco serviceability) and a single option click
  doesn't complete it, so the final yes/no API call isn't captured.
- Current interpret() falls back to scanning UI text and produces FALSE
  POSITIVES (a rural out-of-Cogeco address returned available=true off
  generic marketing copy). Needs the real Retrieve+serviceability
  endpoint wired before it can be trusted.

Next: capture the post-selection Retrieve + serviceability call (likely
needs a "continue" step and handling the multi-dwelling "N Addresses"
branch), then parse the real verdict + speeds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:56:05 -04:00
louispaulb
ab57a3e135 fix(reports/legacy): freshness from service date, not invoice date
Marc Robidoux flagged as overpriced (129.95$) — he has a loyalty discount
(service id 74448) that should lower it. Investigation: 74448 doesn't
exist in the copy (its max service id is 74393), so the discount was added
after the snapshot. Same freshness issue as Julie Dupuis — not a calc bug.

But this also exposed that the freshness banner was wrong: it read the
newest INVOICE date (Apr 30) while the snapshot actually carries SERVICES
created through May 22 — May's recurring billing run simply hadn't executed
at dump time, so invoices lag services by ~3 weeks. For a report that reads
active services/plans/discounts, the service date is the right freshness
signal.

fetchDataAsOf now returns both {services, invoices}; data_as_of (shown in
the banner) is the service date (May 22), with last_invoice kept for
reference. The copy is ~10 days stale, not ~1 month. Marc's loyalty credit
still won't show until the copy is refreshed (task #38).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:57:09 -04:00
louispaulb
7413743572 fix(reports/legacy): include Internet equipment cats to capture all discounts
User flagged Claude Bergeron at 99.95$ "including TV". Investigation: the
report already excludes TV (cat 33/34) — his cat-32 Internet subtotal was
94.95. The real issue was the opposite of what it looked like: a -60$
RAB_FTTH_URBA discount on his account lives in cat 26 ("équipement fibre"),
which the report did NOT count. His true net Internet is 44.95$, so he
should drop off the >90$ list entirely (and now does).

Internet equipment categories (26/29 fibre, 7/8 wireless) carry recurring
items that belong on the Internet bill:
  - modem/router rentals: FTTH_LOCMOD +10, LOC_TPL +5, LOCRTHG8245 +6.95
  - Internet discounts:    RAB_FTTH_URBA (hijacked to -60 for Claude)
Added them to CAT_INTERNET_CORE. The existing price_recurr_type=1 filter
still drops one-time install charges (INSTFIBRE -199, etc.) that share
these categories. Verified HVSECTOUR/INSTTELE (odd high-price items in
cat 7/8) have zero active residential services — no aberrations introduced.

Net effect: the report's "net Internet" is now truly net of every
recurring Internet discount, wherever it's categorized. Residential >90$:
554 → 739 (modem rentals legitimately lift borderline bills; deep
equipment-category discounts like Claude's pull others below the line).
TV and téléphonie remain fully excluded.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:46:58 -04:00
louispaulb
94ebb822db feat(reports/legacy): data-freshness banner + recently-expired-discount column
User correctly spotted that Julie Dupuis shows 114.95$ but actually pays
69.95$ — investigation revealed the legacy COPY (legacy-db container) is a
one-shot snapshot from 2026-05-05 with data through 2026-04-30 and NO
auto-sync. She renegotiated in May (a -50$ discount on service 50999) which
the copy never received. The report was correct vs the copy, but the copy
is ~1 month stale.

Two changes (data-source strategy still pending operator decision —
prod 10.100.80.100:3306 is reachable for a future live/refresh option):

1. data_as_of — the report now reports MAX(invoice.date_orig) from the
   copy and the Ops page shows a banner ("Données legacy au 30 avril —
   copie figée, N jours"). Turns orange past 7 days so nobody acts on
   stale prices unknowingly.

2. recent_expired_discount column — per-address sum of deactivated credit
   lines (status=0, price<0) whose actif_until fell in the last 180 days.
   Surfaces clients whose discount just lapsed (Julie's RAB24M -15 + RAB_X
   -35 expired 2026-03-01), i.e. the prime retention targets whose bill is
   about to jump. Shown in amber with a warning icon + tooltip; included in
   the CSV.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:31:41 -04:00
louispaulb
8a9df4b85e fix(reports/legacy): active clients only — exclude terminated + non-customer accounts
User flagged that several listed accounts are inactive (Or Viande Inc,
Denis Henderson). Root cause: I filtered service.status=1 but NOT the
account, so terminated accounts carrying an orphan active service line
slipped through. The legacy billing job (LEGACY-ACCOUNTING-ANALYSIS.md
§6.1) bills only when BOTH service.status=1 AND account.status=1.

Three account-level filters added:
- account.status = 1   → drops terminated accounts. Or Viande Inc is
  status=4, terminated 2014 (terminate_date set), but still had a
  service.status=1 row. 8602 accounts are status=4 vs 6537 status=1.
- account.group_id = 5 → "Client" per account_group. Drops 6 Prospect,
  7 Fournisseur, 8 Relais (network infra, e.g. Denis Henderson's
  REL_CHRY_CHARLES tower account), 10 Équipement motorisé.
- customer_id NOT LIKE 'PROPRIO%' → 59 landowner-hosts-our-gear accounts
  that live in group 5 but aren't paying customers (Denis Henderson's
  other account PROPRIOH_STCHARLES). A genuine same-name customer
  (Robert Henderson, ROBEH...) correctly stays.

Residential >90$/mo: 983 → 554 (was inflated ~44% by dead/non-customer
accounts). Commercial: 255 → 240.

Ops page note updated to state "comptes clients actifs uniquement" and
list what's excluded.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:21:24 -04:00
louispaulb
b631fabf91 fix(reports/legacy): exclude expired credits; confirm monthly price model
Reviewed against docs/archive/LEGACY-ACCOUNTING-ANALYSIS.md (the migration
audit) which surfaced two things to check in the overpriced-internet report:

1. service.payment_recurrence (0=annual, 2=monthly, 5=semestrial...) —
   checked whether per-cycle prices needed /N normalization. They do NOT:
   verified a semestrial FTTH1500I carries product.price=109.95, identical
   to the monthly one (billed 6×109.95 every 6 months). Per §6.1
   "prix = quantité × prix_unitaire", product.price is already the monthly
   unit price. The original monthly logic was correct — no division. The
   SKU-LIKE-'%ANN' /12 special-case stays (true annual plans where price
   IS the yearly amount, e.g. FTTH_ANN @ 480$/yr).

2. Promo credits carry an actif_until end date (§10). A discount line whose
   actif_until is past no longer reduces today's bill, so counting it
   understates what the client actually pays. Now excluded.

   NULL-safety: the exclusion needs an explicit `actif_until IS NOT NULL`
   guard — without it, `NOT (price<0 AND actif_until>0 AND actif_until<now)`
   evaluates to NULL for permanent credits (actif_until NULL), which SQL
   treats as not-true and silently DROPS every permanent credit line. That
   briefly inflated the residential count to 3330; with the guard it's a
   correct 1000 (vs 983 before — +17 addresses whose only sub-90 reason was
   a now-expired credit).

Net effect: the report reflects the *current* real monthly Internet bill.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:12:49 -04:00
louispaulb
7f06c254c8 feat(ops/reports): "Internet trop cher" legacy report
New Ops report to surface clients whose net monthly Internet bill
exceeds a threshold — for spotting plans that should be revised.

Hub (lib/legacy-reports.js — new module, read-only MariaDB):
- GET /reports/legacy/overpriced-internet (+ .csv variant)
- Queries the legacy gestionclient DB directly via a small mysql2 pool
  (reuses cfg.LEGACY_DB_* — same vars as auth.js sync-legacy; added
  LEGACY_DB_PASS to the hub .env which was previously unset).
- Grain = delivery (service address), NOT account: a multi-unit
  building (account 13166 has 82 doors / 205 services) would otherwise
  show a single bogus $2117 line instead of ~45 per door.
- Net monthly Internet = SUM of effective per-line price across
  Internet categories (32 fibre, 4 wireless, 23 camping + optional
  add-ons 16/17/21), discounts included (products with price<0 are
  recurring credits like RAB24M -15$).
- Effective price = service.hijack ? hijack_price : product.price.
- Only recurring lines (product.price_recurr_type=1) — excludes
  one-time equipment/install charges.
- Annual plans (SKU LIKE '%ANN', e.g. FTTH_ANN @ 480$/yr) normalized
  /12 so they compare correctly against a monthly threshold (was
  falsely showing $480 → now $40, drops below 90$).
- Excludes TV (33,34) and téléphonie (9) entirely.

Validated counts at 90$/mo: 983 residential, 297 commercial addresses.

Ops UI:
- src/pages/ReportInternetCherPage.vue — threshold/segment/add-ons
  filters, summary cards (count, total monthly, avg, discounts),
  sortable+filterable table (client, address, net, gross, discount,
  plan detail with full tooltip, contact), CSV download.
- Card on the Rapports hub + route /rapports/internet-cher.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:06:05 -04:00
louispaulb
0fb9089f4e fix(campaigns/templates): center logos via nested-table pattern
The native-block imageBlock factory was emitting img tags wrapped only
by a td with text-align:center. That doesn't actually center the image
because text-align only affects inline content, and the img has
display:block. The result: top header logo and dark-footer logo were
left-aligned despite the textAlign:"center" prop on the block.

Fix: wrap each img in an inner <table align="<textAlign>"> exactly the
way MJML/Litmus/Mailchimp do it. This is the canonical email-client
pattern that works in Outlook 2007-2019 (which ignores margin:0 auto
on inline tables but respects table align attributes).

Also: the AI converter dumped the entire dark footer band into a
SINGLE htmlBlock with malformed table markup (a stray </td> outside
its row). Split into proper image + text native blocks so:
  1. The logo inherits the new centered nested-table pattern
  2. The URL+copyright text is now individually editable in Unlayer
  3. The {{year}} placeholder is in a text block where it belongs

And one AI hallucination correction: the converter assigned
textAlign:"left" to the top header logo (probably because the
surrounding column had align="left" in the MJML output). Original
design intent was centered — fixed in the spec.

Verified live: both logos (140px top, 120px footer) now render with
align="center" on their nested wrapper table.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 18:45:30 -04:00
louispaulb
0fd1e9f6b5 feat(campaigns/templates): Gemini-powered HTML→native converter
Scales the native-block migration from "one template per manual spec"
to "any compiled .html template, one CLI command, ~5 seconds, ~$0.001
per template" via Gemini Flash semantic interpretation.

Pipeline (ai-convert-to-native.js):
  1. Read existing compiled .html
  2. Send inner body to Gemini Flash with a tight JSON schema
     (block.type ∈ text/image/button/divider/html, plus type-specific
     fields like fontSize/color/padding/href).
  3. AI returns { preheader, ariaLabel, blocks: [...] }
  4. Deterministic emit of a templates-spec/<name>-native.js file —
     no AI-touched markup goes into the final compiled output.
  5. Validation: every {{var}} in source MUST survive into the spec;
     warn loudly if any are dropped (the AI occasionally omits minor
     placeholders like {{year}} in the copyright line).

Why deterministic emit matters:
  Gemini understands SEMANTICS reliably ("this paragraph is the
  greeting, this div is the CTA, this span is a chip") but
  hallucinates DETAILS when generating final HTML. Splitting the
  responsibilities means the AI only outputs structured JSON
  describing the layout, and build-native-template.js produces the
  bytes shipped to recipients.

First conversion: gift-email-fr → gift-email-fr-native
  - 15 blocks identified by Gemini in 3006 tokens (Flash, ~5s).
  - 4 row groups: view-in-browser, white card (intro/chips/CTA/
    footer copy), contact info, dark footer band.
  - 7 text + 1 image + 1 button + 6 html blocks (chips, multi-logo
    strip, brand-logo card, expiry section stay as raw HTML —
    correct, those have no native equivalent).
  - HTML payload: 19,664 bytes vs original 39,913 bytes — **-51%**.
  - One AI omission caught by the new sanity check: {{year}} was
    stripped from the © line in the dark footer. Hand-patched in the
    generated spec. Re-running with stricter prompt should reduce
    that occurrence rate.

Hub preview endpoint now defaults vars.year to current year (matches
the test-send endpoint that already did this), so the sample render
shows "© 2026 TARGO Communications" instead of "©  TARGO ...".

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 18:29:15 -04:00
louispaulb
2919fa86af feat(campaigns/templates): native-block reminder template (proof of concept)
Until now, every Unlayer-edited template stored as a single giant
"Custom HTML" block (~37 KB). The operator couldn't manipulate the
greeting, the CTA, or the expiry badge independently — they had to
edit raw HTML inside one block.

New scripts/build-native-template.js generates matched .json
(Unlayer design tree) + .html (compiled output) from a JS template
spec under scripts/templates-spec/. Each block becomes a separate
entry in the design tree with its own type:
  - 9 text blocks  : greeting, urgency, body, expiry, prorata,
                     Option 2 text, signature, contact, dark footer
  - 2 image blocks : header logo, footer logo
  - 1 button block : the CTA (🎁 {{amount}})
  - 4 html blocks  : view-in-browser, Option 1 chip, brand-logo
                     card, Option 2 chip (kept as raw HTML — too
                     custom for native equivalents)

gift-email-native-reminder-fr ships as the proof of concept:
- Compiled HTML: 30,867 bytes (vs 39,484 for the MJML-compiled
  reminder-fr — saves 22%)
- JSON: 42,274 bytes (essentially same as before, but now broken into
  16 individually-editable blocks instead of 1 monster Custom HTML)

What this unlocks in Unlayer:
- Click any text → font / color / size / padding / alignment in the
  right panel
- Click the CTA → button-specific controls (corner radius, hover
  color, padding)
- Drag-reorder blocks within the email
- Mobile preview reflects each block's responsive defaults
- Save a block to the personal library for reuse in other campaigns

Limitations on the 4 html blocks:
- Chips (Option 1 / Option 2) require raw HTML edit because the
  rounded badge styling has no native equivalent
- Brand-logo strip needs precise inline img widths Unlayer can't set

Once the operator validates rendering across Gmail/Outlook/Apple
Mail, we'll port the rest: gift-email-fr/en + the existing reminder
templates can all migrate using the same build script.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 15:31:15 -04:00
louispaulb
8410464a22 feat(campaigns/reminder): cascade clicks to parent + family banner
A reminder campaign is a deep-copy of its parent's non-clicked
recipients with NEW gift_tokens. Clicks on the reminder were flagging
the CHILD recipient's gift_link_clicked but the parent campaign's
counters never updated — operators had to check two campaigns to see
the cumulative click rate.

Hub:
- New cascadeClickToParent() helper — when a recipient with
  parent_campaign_id is flagged as gift_link_clicked, mirror the flag
  + timestamp onto parent.recipients[parent_row_index] and broadcast
  a recipient-update SSE event so the parent's open page refreshes
  live. Adds a gift_clicked_via_reminder breadcrumb (the child
  campaign id) so the parent UI can show "↩ via la relance XXX".
  Idempotent — already-clicked rows are no-op.
- Three cascade call sites: applyWebhookEvent fast path (CustomID),
  applyWebhookEvent fallback (msgId scan), handleGiftRedirect wrapper.
- handleGiftRedirect also now sets gift_link_clicked=true on first
  successful redirect (Mailjet webhook can lag or drop; the wrapper
  redirect is the most reliable click signal we have).
- GET /campaigns/:id now attaches a "reminders" array with summary
  counters for every reminder child of the campaign.

Ops UI:
- "Cette campagne est une relance" banner on child detail pages with
  a back-link to the parent.
- "N relance(s) envoyée(s)" banner on parent detail pages with
  clickable chips showing each child's gift_clicked/total ratio.
- Recipient table: 🔁 icon next to the gift-click indicator when the
  click came via reminder, plus a "↩ via la relance XXX" line in the
  tooltip so the operator can trace the engagement channel.

One-time backfill applied on prod to mirror clicks that happened
between reminder send and this deploy (1 click cascaded —
cmp-20260522-2d4605 gift_clicked 27 → 28).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 15:24:52 -04:00
louispaulb
e7b937e2a3 style(campaigns/reminder): add "pour te remercier" / "as a thank-you"
Surfaces the WHY of the gift in the reminder body. The original
campaign was sent ~10 days before the reminder fires — recipients
may have forgotten the loyalty/gratitude context, leading to a
"what is this?" reaction when they see the reminder cold.

Adding two words ("pour te remercier" / "as a thank-you") cheaply
reconnects with the original messaging and reinforces TARGO's
relationship framing.

FR: "La carte-cadeau qu'on t'a envoyée pour te remercier peut
     s'utiliser chez des centaines de marques canadiennes..."

EN: "The gift card we sent you as a thank-you can be redeemed at
     hundreds of Canadian brands..."

Greeting kept as "Petit rappel pour {{firstname}}," — first-name
personalization beats generic "toi" on engagement metrics, and the
firstname auto-clean covers ~99% of recipients.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 15:09:41 -04:00
louispaulb
a07b45235a fix(campaigns/expiry-picker): show + save dates in America/Montreal TZ
The edit-params picker was showing "2026-06-22" for an expiry stored
as 2026-06-22T03:59:59Z because it sliced the UTC string. But that
UTC instant is actually 23:59 EDT on June 21 in Montreal, which is
what the email recipient sees (and what the operator picked).

Fixes both sides of the round-trip:

DISPLAY (UTC → picker)
- Convert stored ISO UTC to YYYY-MM-DD interpreted in America/Montreal
  using en-CA locale (which returns ISO-style YYYY-MM-DD).

SAVE (picker → ISO UTC)
- New endOfDayMontreal() helper that probes Montreal's offset for the
  target date (noon UTC always lands in morning Montreal, never spans
  a day) and anchors at 23:59:59.999 local. Handles EDT/EST swaps
  automatically — verified with edge cases 2026-03-08 (post-DST-spring),
  2026-06-21 (mid-summer), 2026-11-01 (post-DST-fall), 2026-12-31 (winter).

Previously the save path relied on the BROWSER's local TZ inference
(new Date('YYYY-MM-DDT23:59:59').toISOString()) which is fine for
Quebec operators but quietly wrong for anyone editing from elsewhere.

The bulk email send was already correct because the worker's
toLocaleDateString uses timeZone: 'America/Montreal' (last commit).
This commit only fixes what the OPERATOR sees in the picker.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 15:06:57 -04:00
louispaulb
ddedd60320 fix(campaigns/expiry): format dates in America/Montreal, not container UTC
The targo-hub container runs with TZ=UTC (no override set). Calls to
toLocaleDateString without an explicit timeZone option were rendering
dates in UTC, which meant a wrapper expiring at 23:59 EDT (= 03:59
UTC next day) showed "22 juin 2026" to the recipient instead of the
intended "21 juin".

All 4 date-formatting sites in lib/campaigns.js now pass
timeZone: 'America/Montreal' explicitly:
- worker (sendCampaignAsync) — main send path
- /campaigns/:id/recipients/:i/view — web fallback render
- POST /templates/:name/test-send sample defaults
- POST /templates/:name/preview sample defaults

Verified on prod: stored UTC "2026-06-22T03:59:59Z" now formats
"21 juin 2026" / "June 21, 2026" with the timeZone option, matching
the operator's intent ("expiration en fin de journée le 21 juin EDT").

Also re-patched the relance draft cmp-20260601-f857cd-rem from
2026-06-21T23:59:59Z (= 19:59 EDT, the early-evening cutoff) to
2026-06-22T03:59:59Z (= 23:59:59 EDT, true end of day). Bonus: this
aligns with the original campaign's recipients which expire around
~11:57-12:04 EDT on June 21, so the reminder always works at least
as long as the original — never the inverse confusion.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 14:58:54 -04:00
louispaulb
c55f75739f fix(hub/cors): allow PATCH in preflight Access-Control-Allow-Methods
Browser CORS preflight (OPTIONS) for PATCH /campaigns/:id was
rejected because PATCH wasn't listed in Access-Control-Allow-Methods.
The browser surfaced this as a generic "Load failed" on the
"Enregistrer" button of the edit-params dialog. curl bypasses CORS
so backend testing missed it.

The header now includes PATCH alongside GET/POST/PUT/DELETE/OPTIONS.
Verified live: OPTIONS preflight now returns the full method list.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 14:49:11 -04:00
louispaulb
89057d0166 feat(campaigns/expiry): date picker for explicit cutoff
Operator can now choose an exact date for the wrapper expiry (e.g.
"valid until June 15") instead of computing days from today. Useful
when communicating a specific deadline to recipients.

Worker resolution order:
  1. params.gift_expires_at (full ISO datetime, set by the date picker)
     — all recipients of this campaign get THIS exact date, regardless
     of when the worker fires the send.
  2. Fallback: now() + gift_expiry_days (relative deadline, shifts
     forward by queue lag).

UI in both wizard (new campaign) and edit-params dialog (draft):
- Date picker at the top with cursor-pointer event icon + clear (x)
- Preset toggle (15/30/60/90/180/Custom days) below — auto-disabled
  when explicit date is set so the operator picks ONE mode
- Indicator "≈ N jours à partir d'aujourd'hui" when explicit date is
  active so the operator sees both representations

UI carries the picker value as YYYY-MM-DD (gift_expires_at_display);
launchSend / saveEditParams translate to ISO YYYY-MM-DDT23:59:59Z
before PATCH/POST. Anchoring at end-of-day local means "until June 15"
stays valid through all of June 15, not just the start.

dateAfterToday validator blocks past dates in the picker.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 14:45:14 -04:00
louispaulb
31562f62bf style(campaigns/templates): replace em-dashes with periods/commas
Some readers (and several modern style guides) read em-dashes as
"AI-written" feel — the user preferred a mix of period (for full
clauses) and comma (for asides) to keep the copy conversational
without the long pause em-dashes impose.

Period when both sides are independent clauses:
- about fiber. They're about people too. (EN main + reminder)
- on Giftbit. Just click your X. (EN main + reminder)
- pas manqué. La carte-cadeau qu'on t'a envoyée… (FR reminder)
- didn't miss it. The gift card… (EN reminder)

Comma when the second half is an aside or starts with "and":
- something special, for a limited time. (EN main)
- right next door, and we genuinely love… (EN main)
- aucun souci, pas besoin… (FR reminder)
- no worries, no need to reply… (EN reminder)

gift-email-fr unchanged — its user-visible text never had em-dashes
(the 3 detected were inside HTML comments).

No hub restart needed: the send worker reads templates fresh from
disk on every campaign run, so the new copy applies on the very next
"Lancer l'envoi" click.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:26:59 -04:00
louispaulb
73c42d6997 fix(campaigns/reminder): softer tone + render expiry in tests
The reminder copy read as pushy on test sends ("Hâte-toi! ... Tu n'as
encore rien fait, et le délai approche"). Toned down to factual and
friendly: state availability + offer the no-pressure path.

FR before / after:
   Hâte-toi! Ton cadeau de 60 $ expire le ___.       (red bold)
  → 🎁 Ton cadeau de 60 $ reste disponible jusqu'au 1 juillet 2026.
                                                       (brand dark green)

  Tu n'as encore rien fait, et le délai approche. Si tu n'utilises
  pas ton cadeau d'ici là, il ne pourra plus être réclamé.
  → On voulait juste s'assurer que tu ne l'as pas manqué — la carte-
  cadeau qu'on t'a envoyée peut s'utiliser chez des centaines de
  marques canadiennes, en quelques clics.
  Si tu préfères ne pas l'utiliser, aucun souci — pas besoin de
  répondre à ce courriel.

EN copy mirrored.

Also: {{expires_at_date}} was rendering empty in test sends and
previews because neither the test-send endpoint, the preview
endpoint, nor the editor's testSendForm.vars seeded it. Three fixes:
- Hub preview endpoint: compute now+30d as default sample date.
- Hub test-send endpoint: same default + expose view_url='' so the
  Mustache section block collapses cleanly in internal tests.
- Editor test-send dialog: pre-fill expires_at_date (and expires_in_
  days) with the same now+30d value, plus expose both fields as
  editable inputs so the operator can override per-test.

Verified live on prod: the preview endpoint with no vars now renders
"Ton cadeau de 60 $ reste disponible jusqu'au 1 juillet 2026."

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 11:55:20 -04:00
louispaulb
e64e1e6a1f feat(campaigns/detail): edit draft params + jump to template editor
Two new buttons on the campaign detail page header — both visible only
when campaign.status === 'draft' to keep operators from accidentally
mutating a campaign mid-send.

"Éditer les paramètres" → q-dialog with:
- name (internal)
- subject (the email Subject: line)
- from (sender)
- amount displayed in the body (overrides per-recipient default)
- commitment_months
- expiry text
- template_fr / template_en dropdowns (refresh on popup-show so newly
  created templates show up without a page reload)

Saves via the existing PATCH /campaigns/:id, which merges into
params. A live load() refresh updates the Confirmation recap and any
visible counters.

"Éditer le template" → opens the Unlayer editor in a new tab on the
campaign's configured template_fr (most TARGO customers FR). For
campaign-specific tweaks the dialog tells the operator to create a
variant template (+ Nouveau) and select it here.

Addresses the gap a user hit on a reminder draft — they wanted to add
a condition to the body before launching but had no edit affordance
on the detail page.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 11:51:45 -04:00
louispaulb
f414975b00 feat(campaigns): reminder campaign for non-clickers
Adds a "Créer une relance" button on the campaign detail page that
clones the parent campaign into a new draft, targeting only the
recipients who haven't clicked the Giftbit gift link yet.

Backend (POST /campaigns/:id/reminder):
- Filters parent recipients: status sent/opened, not excluded, not
  revoked, wrapper not yet expired, has a gift_url.
- Builds a fresh recipients array — same gift_url (Giftbit shortlink),
  same name/email/language/amount, but cleared gift_token so the worker
  generates a brand-new wrapper at send time. Each campaign owns its
  own click metrics.
- New campaign starts as 'draft' so the operator can review, tweak
  subject/template, and click "Lancer l'envoi" when ready.
- Tracks parent_campaign_id + parent_row_index on each reminder row
  for traceability in CSV reports and debugging.

Templates (gift-email-reminder-fr / gift-email-reminder-en):
- Header swap: "Petit rappel pour {firstname}" / "Quick reminder, X"
- Bold orange urgency line: " Hâte-toi! Ton cadeau de X expire le Y"
  using the existing {{expires_at_date}} and {{amount}} merge vars
- Body shortened — drops the manifesto, focuses on "you have a gift,
  redeem before it's gone"
- Same CTA button + prorata disclaimer + signature + footer as the
  main templates so brand stays consistent.

UI:
- Button visible when campaign is sending/completed AND it's not
  itself a reminder AND there's ≥ 1 eligible non-clicker.
- Confirmation dialog spells out the mechanics: same Giftbit URLs,
  new wrapper tokens, reminder template, sample expiry date pulled
  from the campaign's first recipient with a gift_expires_at.
- On OK, redirects to the new campaign's detail page.

Click stats on the existing campaign (cmp-20260522-2d4605) verified
intact before+after deploy (109 opens, 15 generic clicks, 27 gift CTA
clicks) — saveCampaign persists per-event so the hub restart was a
no-op for accumulated data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 11:43:35 -04:00
louispaulb
6577bb79bc feat(campaigns/send): real SMTP error + auto-retry + one-click Renvoyer
The send worker used to write "SMTP send returned false (see hub logs)"
on every failure, forcing the operator to SSH into the box to find the
actual cause. Now we capture the real reason and surface it in the UI.

Three changes:

1. lib/email.js exposes getLastError() — a side-channel for the most
   recent nodemailer error message, cleared at the start of every
   sendEmail call. Legacy "if (await sendEmail(...))" callers stay on
   the false-return contract; only the campaign worker reads the
   side-channel for detailed error capture.

2. The worker now retries each recipient up to 3 times (initial +
   2 retries with 2s/5s backoff). Most "Unexpected socket close"-style
   transient Mailjet errors recover on the second attempt. We observed
   exactly this case for Myriam Bergevin in cmp-20260522-2d4605 — a
   single socket close interrupted 1 of 202 sends; auto-retry would
   have caught it. retry_count is now stored on the recipient.

3. POST /campaigns/:id/recipients/:row/retry resets a single failed
   row back to pending and re-fires the worker. Surfaced in the
   detail-page table as a small 🔁 button next to the error text on
   any row with status=failed. Useful when auto-retry exhausted its
   3 attempts on a one-off transient.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 13:29:25 -04:00
louispaulb
d5ee57acf2 feat(campaigns/wizard): inspectable dropped-row list with one-click recovery
parseMapCsv now collects the actual rows it drops (capped at 200),
each with its skip reason and the raw source-CSV columns (full_name,
email, phone, address, postal). Returned alongside the existing
counters as skipped_rows on the parse response.

Wizard Step 2 adds an "N ligne(s) du Map CSV non importée(s)"
expansion below the imbalance banner, showing:
  Ligne # | Raison | Nom au CSV | Email au CSV | Adresse | CP | →

The action column has a "Ajouter manuellement" button on rows that
have an email (duplicate, multi_skip) — clicking opens the manual-
add dialog pre-filled from the dropped row, so the operator can
recover the contact in two clicks. no_email rows can't be recovered
that way and don't get the button.

The source_row index is the Excel-relative line number (counting the
header) so the operator can cross-reference the actual file.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 11:55:48 -04:00
louispaulb
5b5df954c1 fix(campaigns/wizard): always show parse summary, even on 0 drops
The previous breakdown only rendered when at least one of the drop
counters was > 0. When the Map CSV cleanly parses every row and the
imbalance comes purely from the Giftbit CSV having more entries than
the Map CSV, the operator was left with "13 surplus gifts" and no
explanation.

The summary now always shows "Map CSV: N raw rows → M contacts paired"
and, when no rows were dropped, explicitly states that the imbalance
must come from the Giftbit side (asks the operator to confirm the
generated gift count matches the Map file).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 11:49:30 -04:00
louispaulb
5c55087198 fix(campaigns/parse): keep no-name rows + surface skip breakdown
Map CSV rows that had a valid email but no name in the source column
were silently dropped at parsing — that's why a campaign would end up
with N unpaired Giftbit shortlinks for N "missing" contacts that
weren't actually missing, just nameless.

The send worker already handles a missing firstname by substituting
"cher client" / "dear customer", so dropping the row was wasteful.
Now we keep the contact and surface a name_warning on the row so the
operator can either edit the firstname in Step 2 or accept the
default.

Also added counters for previously-silent skip paths:
- duplicate: row's email was already seen above (1 gift / household
  consolidation, depending on the multi setting)
- multi_skip: couple skipped because multi='skip' was selected

Wizard Step 2 imbalance banner now ventilates the skip breakdown so
the operator understands exactly where the N "missing" contacts went:

  Ventilation des contacts droppés au parsing du Map CSV (sur 213
  lignes brutes) : 8 sans email valide · 5 emails en double · 0
  couples ignorés · 3 sans nom (gardés, utilisent "cher client" à
  l'envoi)

Unrelated reassurance on the question that triggered this: language
fallback to French is already in place (matchCustomer returns
language:'fr' on miss, worker reads (r.language || 'fr')) so any
unmatched recipient gets the FR template, never an English one by
default.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 11:43:22 -04:00
louispaulb
f6d06d9b34 fix(campaigns/match): handle multi-email + dupe SLs + missing postal
Three independent bugs surfaced while debugging why Alexandre Duval
showed as "non lié" in a campaign:

1. ERPNext Customer.email_id can hold multiple addresses joined by
   ';' or ',' (211 records inherited from Legacy migration). The
   exact-match filter missed them. Now LIKE-searches a window then
   validates locally by splitting on ; , or whitespace.

2. Service Locations have duplicates at the same address — the same
   "7 Rue des Merles" exists 3 times, linked to 3 different customers
   (legacy migration artifact). The civic+postal strategy was taking
   the first hit which could be the wrong household. Added name-aware
   disambiguation: when the recipient has a name, walk the candidates
   and pick the one whose linked Customer name plausibly matches.

3. New 4th matching strategy "name+civic" — kicks in when the CSV
   row has no postal_code (most common Map export failure mode). Does
   a street-word filtered SL search and accepts only candidates whose
   Customer name plausibly matches. Confidence 0.65 (vs 0.85 for
   civic+postal).

Also: SQL filter for both civic+postal and name+civic now includes a
street-word LIKE constraint so the result set isn't dominated by
unrelated "7 ..." addresses, bumped limits to 50/100. The SL
denormalized customer_name field is often empty post-import — we now
fall back to a Customer lookup for the name check.

Verified end-to-end against live ERPNext: Alexandre Duval at
7 Rue des Merles now matches correctly via email (multi-value field),
via civic+postal (despite 3 dupe SLs), and via name+civic (no postal).
Gaëtan David at the same address also matches correctly without
collision.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 11:36:51 -04:00
louispaulb
85ad66f103 feat(campaigns): one-click Giftbit admin lookup per recipient
Manual workaround for redemption status until /gifts/{uuid} polling
ships (task #25). The trailing path segment of the Giftbit shortlink
is the lookup key for Giftbit's admin search:

  http://gft.link/4kpZMApLK4Bhttps://app.giftbit.com/app/rewards?search=4kpZMApLK4B

Surfaced in three places:
- Inventory page row: 🔗 button next to the copy-URL action
- Campaign detail page recipient table: same button next to the
  Giftbit shortlink
- CSV report: new giftbit_admin_url column for bulk audits in Excel
  (one click per row, no manual concat)

Defensive: only renders if the trailing segment is ≥4 chars (avoids
producing useless searches on malformed/test URLs).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 10:58:33 -04:00
louispaulb
feeae6eb40 feat(campaigns/templates): visible wrapper-expiry date in the email
Two new template variables are auto-derived from r.gift_expires_at at
render time (separately by the worker and the /view fallback to keep
them consistent):

  {{expires_at_date}}  locale-formatted FR/EN long date — "21 août 2026"
                       / "August 21, 2026". Empty when no wrapper token.
  {{expires_in_days}}  remaining days as string (rounded up). Useful
                       for tight deadlines where a date is too distant
                       to convey urgency.

Templates: a small centered badge appears between the CTA button and
the prorata disclaimer, wrapped in a Mustache section so it disappears
cleanly on campaigns that pre-date the wrapper feature.

   Cadeau valide jusqu'au <strong>21 août 2026</strong>
   Gift valid until <strong>August 21, 2026</strong>

Editor merge-tag panel updated so authors can drop these into custom
copy without remembering the exact variable names. The legacy
{{expiry}} field stays — it's still the right tool for promotion-end
dates that don't track the gift link's own deadline.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 10:47:58 -04:00
louispaulb
d529019106 feat(campaigns): gifts inventory page + expiry presets
Wizard: gift_expiry_days now lives behind a preset toggle
(15/30/60/90/180 + Custom) instead of a naked number input. Operator
clicks a chip; the value flows back into the existing campaign param.

Inventory page (/campaigns/gifts):
- Cross-campaign view of every wrapper token with status taxonomy
  (active / redeemed / expired / revoked / pending). Each card on
  the counters strip is a click-to-filter shortcut.
- "Réassignables" highlighted in amber when > 0 — these are gifts
  whose wrapper expired or was revoked but the Giftbit URL is still
  unredeemed, ready for a fresh recipient.
- Search across name/email/url/token; per-status and per-campaign
  filter dropdowns.
- One-click copy on the Giftbit URL with a tailored toast that walks
  the operator through the reassignment workflow (paste into manual-
  add dialog of a new campaign).
- Revoke action with confirmation; explicit about what survives
  (the Giftbit URL stays valid on their side) vs what changes (our
  wrapper stops redirecting).

Backend:
- GET /campaigns/gifts flattens every recipient with a gift across
  every campaign — single-shot, no pagination yet (we're under 10k
  gifts total).
- POST /campaigns/:id/recipients/:row/revoke sets gift_revoked=true
  and broadcasts the recipient-update SSE event.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 10:21:05 -04:00
louispaulb
c0ca5feb6f feat(campaigns): gift redirect wrapper — own expiry + reusable links
Each campaign recipient now gets a short opaque token (10 base64url
chars, ~60 bits entropy). The email contains

  https://msg.gigafibre.ca/g/<token>

which 302-redirects to the underlying Giftbit shortlink — but ONLY if
the recipient hasn't passed our own expires_at and we haven't revoked
the token. This gives us two new operational capabilities:

1. End-date control independent of Giftbit. The wizard now has a
   "Expiration interne (jours)" field (default 90) that sets our
   own deadline. Useful when the Giftbit gift is valid 12 months
   but the campaign offer should expire in 30 days.

2. Reuse of unredeemed gifts. After our expiry, the old wrapper
   stops working but the Giftbit URL is still valid on their side.
   Pasting that same gift_url into a new campaign (via the manual-add
   dialog) generates a NEW token pointing to the same Giftbit gift —
   the original recipient's old wrapper URL says "expired", the new
   recipient gets a fresh window.

Per-recipient new fields:
- gift_token            short ID used in the wrapper URL
- gift_expires_at       ISO timestamp of our cutoff
- gift_revoked          manual kill-switch (false by default)
- gift_redirected_count clicks that successfully reached Giftbit
- gift_first_redirected_at  first successful redirect timestamp

Routing:
- GET /g/:token  — public, validates and 302s (or expired-page)
- Mailjet click event handler updated to recognise wrapper URLs
  alongside legacy gft.link/giftbit.com URLs.
- /view (browser fallback for in-email rendering) also wraps the
  gift link so expiry/revoke is honoured consistently.

Bootstrap rebuilds the in-memory token→recipient index by scanning
all campaign JSONs on startup — no separate index file to keep in
sync.

CSV report adds gift_token, gift_expires_at, gift_revoked,
gift_redirected_count, gift_first_redirected_at.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 10:15:43 -04:00
louispaulb
1c5241df69 fix(campaigns/wizard): template dropdowns now show non-suffixed templates + refresh
Two issues with the per-language template dropdowns:

1. Strict filter — only -fr / -en templates appeared. Anyone naming a
   template gift-email-test or gift-email-es (no recognized language
   suffix) saw nothing show up in either dropdown.

2. Loaded once on mount — creating a template in another tab and
   switching back to a wizard already open kept showing the stale list.

Fix:
- Templates without a -fr / -en suffix are added to BOTH dropdowns
  with a "· sans suffixe de langue" tag so they're discoverable but
  visually distinct from the recommended ones.
- Sort: matching-suffix templates first, then alphabetical.
- @popup-show triggers a refresh on every dropdown open.
- Visible "refresh" icon in the dropdown's append slot for manual
  triggering without having to close/reopen the popup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 09:51:29 -04:00
louispaulb
5330fecf43 feat(campaigns/wizard): per-language template override
Two new dropdowns in Step 1 ("Template français" / "Template anglais")
populated from /campaigns/templates filtered by suffix (-fr / -en).
Selection is stored on campaign.params.template_fr / .template_en
and the worker resolves the actual path via a new resolveTemplatePath
helper:

  1. params.template_<lang>  (per-lang override, set here)
  2. params.template_path    (legacy single-template campaign override)
  3. templateForLanguage()    (default gift-email-<lang>.html)

Defensive name regex inside resolveTemplatePath blocks path traversal —
operator can pick any *-fr / *-en template that exists, nothing else.

The Step 3 summary list now shows which template will actually ship
per language so the operator can sanity-check before launch.

Use cases: seasonal variants (gift-email-2026-summer-fr), A/B tests,
draft templates that aren't ready to be the default yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 09:46:58 -04:00
louispaulb
9450dd34db feat(campaigns): delete campaign from the list
DELETE /campaigns/:id removes the JSON from /opt/targo-hub/data/campaigns/.
The Giftbit shortlinks already issued for that campaign live on Giftbit's
side and are unaffected — this is purely about clearing internal tracking
records (typically test runs cluttering the list).

Refuses (409) while the send worker is active for that id so we never
yank the file out from under saveCampaign(). Defensive id regex
(in campaignPath) blocks path-traversal attempts before unlink runs.

UI: red trash icon on each row, disabled while status=sending.
Confirmation dialog spells out what survives the deletion (Giftbit
links) vs what's lost (tracking, opens/clicks, CSV report) so the
operator isn't surprised.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 09:33:44 -04:00
louispaulb
4babb403e8 feat(campaigns): "View in browser" web fallback for failed inbox renders
GET /campaigns/:id/recipients/:i/view re-renders the campaign with the
same vars the worker used at send time — same template, same per-row
amount override, same language pick. Useful when the recipient's mail
client butchers the layout: image-blocking, antique Outlook, niche
third-party apps, accessibility tools.

Templates: Mustache section {{#view_url}}…{{/view_url}} guards a tiny
gray link above the header logo (11px, #94a3b8). The section collapses
to nothing when view_url is empty, so:
  - the /view page itself doesn't show the link (you're already there)
  - wizard previews / test-sends don't show it (no real campaign id)

worker passes view_url = HUB_PUBLIC_URL + /campaigns/<id>/recipients/<i>/view
using the existing cfg.HUB_PUBLIC_URL setting (defaults msg.gigafibre.ca).

Security: campaign-id is a 21-char nanoid (≈10²¹ space). Same level of
exposure as the Giftbit shortlink itself. X-Robots-Tag: noindex on the
response so the URLs don't end up on search engines.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 09:30:38 -04:00
louispaulb
00f2e735c8 docs: Giftbit API integration overview for production approval
Reviewer-facing document covering company/use-case, why we need
delivery_type=SHORTLINK (brand+language+deliverability), what data
we send (no PII beyond name/email/internal opaque ID), security
posture (token in env, sandbox-by-default with email override
to a single test inbox), CASL compliance, customer experience,
and the planned /gifts/{uuid} redemption polling.

Provided as a single markdown file under docs/ so it can be exported
to PDF for the Giftbit review team.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 09:26:05 -04:00
louispaulb
9fb6fab88e feat(campaigns): distinguish gift-CTA click from generic email click
Mailjet's click event includes the actual URL the recipient clicked. We
previously bumped every click — CTA button, mailto support, footer link —
to status='clicked' indiscriminately. Now we additionally flag clicks on
the Giftbit shortlink (matched by r.gift_url prefix, fallback to gft.link
or giftbit.com host) as the high-signal "gift_link_clicked" event.

Adds:
- recipient.gift_link_clicked (bool) + gift_clicked_at (ISO timestamp),
  set on first matching click; later non-gift clicks don't unset
- counters.gift_clicked aggregated alongside existing status counters
- "Cadeau cliqué" counter card on detail page (deep-purple, redeem icon)
- 🎁 redeem icon next to status chip when the recipient engaged
- CSV report: new gift_link_clicked + gift_clicked_at columns

Why this matters: "opened" is noisy (Apple Mail Privacy Protection, image
proxies prefetch). A click on the CTA is the only reliable indicator
that the offer landed and the recipient is engaging.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 09:22:12 -04:00
louispaulb
10d3745b31 feat(campaigns/editor): "Variables" button — visible merge-tag reference
The previous discoverability path was clic-text → floating toolbar → {}
icon, which assumes the user already knows how to invoke Unlayer's merge
tag UI. A direct "Variables" button now opens a dialog listing all 9
placeholders grouped by category (Client / Offre / Système) with their
sample value and a click-to-copy action. Reads from the same mergeTags
config Unlayer consumes — single source of truth, no drift risk.

Banner inside hints at the upcoming CSV-driven custom variable feature.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 09:14:56 -04:00
louispaulb
bf1253ac58 fix(campaigns/list): "Envois" column counted only status=sent
After Mailjet's Event API webhook moves rows from 'sent' to 'opened' or
'clicked', the counters.sent bucket empties and the list page showed
0/N even though every email had successfully landed. Use the same
sent+opened+clicked sum as the detail page so the list reflects
"emails that left our SMTP" rather than "emails still flagged sent".

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 09:11:15 -04:00
louispaulb
2bc9715485 feat(campaigns): CSV report, manual recipients, template polish
Ops UI
- CampaignDetailPage: "CSV" button — downloads per-recipient report
  (shortlinks, status, opened/clicked timestamps, mailjet UUID)
- CampaignNewPage: "Saisie manuelle (sans CSV)" on Step 1 and
  "Ajouter manuellement" on Step 2 — both open the same dialog with
  firstname / email / gift_url / city / postal_code / language /
  amount override. Indigo "manuel" chip in the recipients table.
- New "Ville" column shows city OR postal_code as fallback.

Hub
- GET /campaigns/:id/report.csv — RFC 4180 CSV with UTF-8 BOM so
  Excel auto-detects encoding. 20 columns including new "city".
- Worker honours per-recipient amount override:
  r.amount > derive from r.gift_value_cents > params.amount > "50 $".
  Fixes manual-add showing campaign default instead of typed value.
- Default subject "Un cadeau pour toi" (tutoyer).

Templates
- Order: Intro →  Option 1 → 🎁 marques → CTA → prorata → ⏭️ Option 2.
- New EN intro (manifesto): "Thank you for choosing local. Your
  support helps keep our community connected. / Because great
  connections aren't just about fiber — they're about people too."
- Amazon logo removed (incongruent with "achat local" framing).
- Body paragraphs: text-align justify (greeting/labels stay left).
- Support line: "N'hésite pas à nous écrire / Feel free to email us"
  + dash format 514-448-0773, drop "Support 7j/7" overpromise.
- Logo style fix: inline width:32px to beat Unlayer canvas CSS that
  was rendering brand pills full-width.

Ignore template converter .bak-*.json backups.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 09:09:07 -04:00
louispaulb
40a2e4e8f2 fix(campaigns/templates): drop 'sans engagement' claim from Option 2
The 'sans engagement ni carte-cadeau' wording in Option 2 was confusing
for customers with an existing multi-month commitment — implies their
subscription is commitment-free, which contradicts their actual contract.

Reworded to make zero claims about the customer's commitment status,
just describes what happens if they ignore the email:

FR:
  before — 'Ne rien faire. Ton abonnement mensuel se poursuit normalement,
            sans engagement ni carte-cadeau.'
  after  — 'Ne rien faire. Aucun changement à ton abonnement actuel.'

EN (Gemini copywriter version was different from earlier templates):
  before — 'Just kick back! Your monthly subscription will continue as
            usual, with no commitment and no gift card.'
  after  — 'Do nothing. No changes to your current subscription.'

Benefits:
  • No false claim about engagement status
  • Shorter, more direct
  • Still preserves the explicit consent UX (customer knows ignoring
    is a valid choice without consequence)
  • No mention of 'no gift card' — that's implicit from not clicking
    the CTA, doesn't need to be stated

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 08:09:39 -04:00
louispaulb
8df17c823a fix(campaigns/templates): inject inline logos into EN template too
The previous logo injection commit (d76a922) only matched the FR anchor —
EN was missed because Gemini's copywriter-mode translation rewrote
'at hundreds of brands' as 'to spend at hundreds of your favorite stores'.
Patched EN with the correct anchor + 'and more' caption.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 08:03:54 -04:00
louispaulb
d76a922777 feat(campaigns/templates): inline merchant logos via simple <img> sequence
The previous attempt (commit 3f72608) tried to inject a nested <table>
of logos, but the anchor selection logic looked for a </p> that didn't
exist — Unlayer renders the amount line inside a <div>, not <p>. As a
result the logos never made it into the templates.

This commit fixes it with a simpler approach: directly append the logo
images to the existing amount-text string. No table nesting, no anchor
hunting — just plain inline-block <img> tags right after the
"🎁 {{amount}} chez des centaines de marques" text.

Markup pattern (inserted right after the amount line, before the
closing </div>):

  <br><br>
  <span style="display:inline-block;">
    <img src="...amazon..." width="32" alt="Amazon" ...>
    <img src="...timhortons..." width="32" alt="Tim Hortons" ...>
    <img src="...walmart..." width="32" alt="Walmart" ...>
    <img src="...homedepot..." width="32" alt="Home Depot" ...>
    <img src="...iga..." width="32" alt="IGA" ...>
    <img src="...homehardware..." width="32" alt="Home Hardware" ...>
    <span style="font-size:13px;color:#64748B;">et plus</span>
  </span>

Each <img> uses display:inline-block + vertical-align:middle so they
sit on the same horizontal line. width=32 attribute set for Outlook;
height:auto in style preserves aspect ratio. margin-right:6px provides
spacing between logos. Caption ("et plus" / "and more") at the end.

Width math (inside 484px-wide pill): 6 × (32 + 6) = 228 px + caption
~50 px = 278 px. Fits with margin to spare.

EN translation auto-detected the equivalent anchor and inserted
"and more" instead of "et plus".

Live test-send verified for both FR + EN.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 08:00:44 -04:00
louispaulb
3f72608a2f feat(campaigns/templates): inline merchant logos + objective prorata phrasing
Two refinements per user feedback:

1. Objective/factual prorata disclaimer (shorter, conditions-of-service tone)
   FR:
     before — "Si tu annules avant {{commitment_months}} mois, tu rembourses
              seulement au prorata des mois restants."
     after  — "Annulation avant {{commitment_months}} mois : seulement à
              rembourser au prorata des mois restants."
   EN:
     before — "If you cancel before {{commitment_months}} months, you only
              refund the prorated amount for the remaining months."
     after  — "Cancellation before {{commitment_months}} months: only the
              prorated amount for the remaining months is refundable."

   The colon-prefixed structure ("X : Y") reads like a T&C bullet rather
   than a marketing sentence — clearer, less wordy, no subject pronoun.

2. Inline row of 6 merchant logos in the offer info pill
   Inserted between the "60 $ chez des centaines de marques" line and the
   "Instant activation" line. 6 most recognizable QC brands at 32px wide:
     Amazon · Tim Hortons · Walmart · Home Depot · IGA · Home Hardware
   Followed by "et plus" / "and more" caption.

   Uses the existing Mailjet-hosted brand logos (same URLs as the 4×3 grid
   in the older rich variant). 32px width fits comfortably on one line
   (~280px total in a 484px-wide pill). Email-safe single-row table layout
   with vertical-align middle, padding-right 8px for spacing.

   Visual effect: instant recognition for the reader — they see the brands
   they'd actually redeem at, without dropping the full 12-logo grid that
   bloated the previous design.

Applied to .html + .json (both FR + EN) via anchor-based injection:
finds the "🎁 {{amount}} chez/at hundreds of marques/brands" paragraph,
inserts the logo table immediately after its closing </p>. Both files
remain valid + the Unlayer editor will pick up the new table next load.

Verified live via test-send (35-37 KB output, recipient queue ok).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:58:55 -04:00
louispaulb
5327112717 fix(campaigns/templates): clearer prorata phrasing — "mois restants" / "remaining months"
Iterating on the prorata disclaimer per user feedback. The previous
version ("tu rembourses le prorata non utilisé (20 $/mois)") still
read ambiguously — "non utilisé" could mean "the portion you haven't
spent" which is conceptually confusing for a one-time gift card, and
the hardcoded "$20/month" tied the template to the specific
$60/3-month campaign.

New phrasing makes the math explicit: refund only for the months
you're NOT staying.

FR:
  before — "Si tu résilies avant {{commitment_months}} mois,
            tu rembourses le prorata non utilisé (20 $/mois)."
  after  — "Si tu annules avant {{commitment_months}} mois,
            tu rembourses seulement au prorata des mois restants."

EN:
  before — "If you cancel before {{commitment_months}} months,
            you refund the unused pro-rated amount ($20/month)."
  after  — "If you cancel before {{commitment_months}} months,
            you only refund the prorated amount for the remaining months."

Wins:
  • Subject ("tu" / "you") explicit — no ambiguity on who refunds
  • Logic clarified — refund == months NOT STAYED, not "unused
    portion of money" (which doesn't quite map to a one-time gift)
  • Generic over campaign params — no hardcoded "$20/month" so the
    template works at any gift amount + commitment combination
  • "annules" (more common in QC consumer-facing) instead of
    "résilies" (slightly more formal/legal-sounding)

Applied via direct find/replace on .html + .json (FR + EN). Live
test-send queued to confirm rendering.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:50:51 -04:00
louispaulb
f37b1d2803 fix(campaigns/templates): clarify who refunds whom for early cancellation
User feedback: the prorata disclaimer was semantically wrong — both FR
("le prorata du montant est remboursable") and EN ("we'll refund the
pro-rated amount") read as if TARGO would refund the customer, when
actually the customer needs to refund the unused portion of the gift
they received if they cancel within the commitment period.

Plus: add the explicit per-month rate ($20/month at $60 / 3 months) so
the customer knows exactly what they'd owe at any cancellation date.

FR:
  before — "🪂 En cas de départ avant {{commitment_months}} mois,
            le prorata du montant est remboursable."
  after  — "🪂 Si tu résilies avant {{commitment_months}} mois,
            tu rembourses le prorata non utilisé (20 $/mois)."

EN:
  before — "🪂 If you decide to leave before {{commitment_months}}
            months, we'll refund the pro-rated amount."
  after  — "🪂 If you cancel before {{commitment_months}} months,
            you refund the unused pro-rated amount ($20/month)."

Both changes:
  • Subject clarified: customer refunds, not TARGO
  • Added explicit per-month value for transparency
  • Kept warm tone (informal "tu" / "you")
  • Mustache {{commitment_months}} preserved

Applied directly to .html + .json via string substitution (preserves
the Unlayer design tree intact except for that one phrase). The
"$20/month" figure is hardcoded for the current $60/3-month campaign;
a future {{monthly_prorata}} computed variable would generalize but
isn't needed yet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:39:01 -04:00
louispaulb
2c47d3269e feat(campaigns/translate): switch from literal to copywriter-mode AI prompt
User feedback: previous prompt produced robotic word-for-word output that
lost the marketing impact. The system prompt was too restrictive on
preservation, suppressing Gemini's natural rephrasing ability.

Rewrote the system prompt with three structural changes:

1. FRAME shifted from "translator" to "senior marketing copywriter"
   The opening line now says "You are NOT translating words — you are
   rewriting marketing copy that lands the same way for a different
   audience." This unlocks idiomatic rephrasing, sentence reorganization,
   active/passive switching, and cultural metaphor adaptation.

2. FEW-SHOT EXAMPLES showing the desired style
   4 FR→EN pairs in the prompt itself, with explicit "NOT: <literal>"
   anti-examples to show what to avoid:
     • "loyauté envers l'achat local" → "keeping it local"
       (not "loyalty to local shopping")
     • "connexions stables et relations durables" →
       "steady connections — both the fiber kind and the human kind"
       (not "stable connections and lasting relationships")
     • "On est juste à côté" → "We're right next door"
     • "Avec l'arrivée de l'été" → "Summer's here"
   These ground Gemini in the brand voice with concrete examples.

3. TONE constraint explicit
   "Warm, conversational, slightly playful. Like a neighbor explaining
   something — never corporate, never stiff." Use of contractions
   ("we're", "you'll") encouraged.

Plus: temperature bumped from 0.2 → 0.7 so Gemini actually exercises
creative rephrasing instead of staying glued to source word order.

Structural preservation rules (HTML, Mustache vars, brand names, emojis,
URLs, technical values like "3.5 Gbit/s") kept as HARD CONSTRAINTS but
clearly separated from the creative freedom on text content.

Live re-translation of gift-email-fr → gift-email-en applied:
  • 51s response time (similar to literal version)
  • 35,934 → 36,067 bytes (slight expansion, normal for EN)
  • Output markers confirm idiomatic phrasing landed:
    "Thanks for keeping it local", "steady connections — both kinds",
    "right next door", "lending a hand", "Summer's here"
  • Mustache vars + brand names + HTML preserved (verified)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:29:16 -04:00
louispaulb
a6cd6ee453 fix(ops/campaigns): v-pre on translation dialog hint to avoid Vue parser crash on {{ '{{...}}' }} 2026-05-22 07:23:35 -04:00
louispaulb
1b399f65eb feat(campaigns): AI template translator via Gemini Flash
New "Traduire (AI)" button in the template editor toolbar. One click
translates the current template's HTML to the opposite language
(detected from the -fr/-en suffix), writing the translated content as
the matching companion template.

Backend (lib/campaigns.js):
- New endpoint: POST /campaigns/templates/:name/translate-to/:targetName
- Reads source .html, calls lib/ai.js aiCall() with Gemini Flash
- System prompt enforces 7 strict preservation rules:
  1. Byte-preserve all HTML tags/attributes/styles/Outlook conditionals
  2. Don't translate Mustache {{vars}}
  3. Preserve URLs/emails/phones/hex colors/CSS/brand names (TARGO,
     Gigafibre, Giftbit, Amazon, IGA, Tim Hortons, etc.)
  4. Preserve emojis (🎁  🤝 🪂  ⏭️ )
  5. Keep the warm informal tone (tu in FR, you in EN)
  6. Translate only visible text inside elements (paragraphs, buttons,
     alt attributes, link text)
  7. Output full HTML doc only, no markdown wrapping
- temperature=0.2 for stable output, maxTokens=32768 to fit ~35 KB HTML
- Sanity validates output isn't truncated (>50% of source size)
- Strips defensive markdown fences if AI ignored rule 7
- Auto-backs up existing target before overwrite
- Regenerates Unlayer design JSON from the translated HTML so the
  editor can reload the translated template visually
- Requires { override: true } in body to overwrite existing target
  (409 Conflict otherwise — protects against accidental clobber)

API client (apps/ops/src/api/campaigns.js):
- translateTemplate(srcName, targetName, { override })

Frontend (TemplateEditorPage.vue):
- "Traduire (AI)" button (purple, icon=translate) in toolbar — disabled
  when current template has no -fr/-en suffix
- aiTranslateTargetName computed: detects source lang from suffix,
  flips to opposite (-fr → -en, -en → -fr)
- Confirmation dialog:
  • Shows source → target template names
  • Info banner explaining what's preserved (HTML, vars, brands, emojis)
  • Amber banner + toggle if target exists (must confirm override)
- On success: positive notification with byte counts +
  "Open" action button to jump to the translated template
- Refreshes templates list after translation so the new file appears
  in the selector dropdown

UX: replaces the previous manual translation workflow (where the user
or I had to maintain two parallel templates). One click now does the
whole round-trip. User reviews + adjusts wording in the EN editor if
the AI translation needs polish.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:21:45 -04:00
louispaulb
d716e69ef6 feat(campaigns/templates): mirror user's FR edits to EN + drop legacy .mjml
User edited gift-email-fr in the Unlayer editor with richer marketing
copy (loyalty thanks, brand manifesto, 3.5 Gbit/s upsell, helpful CTA).
Mirror those edits to the EN template via a one-shot translation script
so the bilingual pair stays in sync for the next campaign send.

Translation strategy: plain-string find/replace mapping with FR
phrases in longest-first order to avoid partial matches. Applied to
BOTH the rendered .html (what the recipient sees) AND the .json
(Unlayer design tree — so re-opening the EN editor preserves the
matching structure).

Mapping coverage:
  • Intro paragraphs (greeting, gift announcement, loyalty thanks,
    brand manifesto, speed upsell, "we're around the corner")
  • Offer info pill (amount, instant activation, commitment)
  • CTA button labels (Activer → Redeem, Choisir → Pick)
  • Prorata refund disclaimer
  • Option 2 "do nothing" text
  • Signature ("Merci de faire rouler" → "Thanks for helping...thrive")
  • Footer contact info + "Tous droits réservés" → "All rights reserved"
  • <html lang="fr"> → <html lang="en">

23/28 translation rules matched; the 5 unused ones were for legacy
phrasing not present in the user's latest save (e.g. the old "Tu
choisis local" line that was replaced by the current intro).

Also: drop the obsolete .mjml source files. Now that Unlayer is the
canonical editor, the MJML→HTML compile pipeline is no longer used
on save (Unlayer outputs HTML directly). The .mjml files were stale
copies from the previous MJML-based editor. Removed from disk on
prod and from git history; rollback via git revert if needed.

Verified live: GET /campaigns/templates/gift-email-en returns the
translated content (9 EN markers detected in HTML). Test-send to
louis@targo.ca queued via Mailjet for visual QA.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:07:05 -04:00
louispaulb
2f1ebae587 fix(hub): templates volume mount must be RW for editor saves
When the Unlayer editor calls PUT /campaigns/templates/:name to save a
design, the hub writes:
  • templates/<name>.html   (compiled email-safe HTML)
  • templates/<name>.json   (Unlayer design tree for editor restore)
  • templates/<name>.bak-<ts>.html  (backup of previous version)

All three need write access to /app/templates inside the container.
The mount was previously declared as :ro, which made these writes
fail with EROFS (read-only filesystem) once the editor was wired up.

Two changes:
  1. Local docker-compose.yml: add ./templates:/app/templates (without
     :ro) and ./uploads:/app/uploads (which was already RW on prod but
     missing from the committed file — local was out of sync).
  2. Prod docker-compose.yml: hot-patched via sed on prod to drop the
     :ro flag, then `docker compose down + up -d` to apply the mount
     change. PUT verified working (returns 200 with size + design_size).

The /app/lib, /app/server.js, /app/public, /app/package.json mounts stay
:ro since the hub never writes to those — keeping the read-only flag
there is defense-in-depth against compromised code overwriting itself.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:49:48 -04:00
louispaulb
73e4118901 feat(campaigns): create new templates from UI + enable Unlayer template library
Two improvements to the template editor:

1. "+ Nouveau" button + creation dialog
   Users can now create new templates from the editor UI without us
   re-deploying the hub. Click "Nouveau" next to the template selector,
   pick a name + prefix + starter (blank or copy from existing), submit.
   The hub PUTs the new template (existing endpoint, no new code needed
   on the backend — just relaxed validation).

   Form:
     • Type (prefix): gift-email / newsletter / transactional
     • Name suffix: lowercase letters/digits/dashes (e.g. summer-2026)
     • Starter: "Vide" or "Copier depuis <existing template>"

   On submit:
     • If starter != blank: GET source template's html + design
     • PUT new template name with that content
     • Refresh templates list + switch editor to the new one

2. Backend: replace hardcoded EDITABLE_TEMPLATES allow-list with
   regex-validated prefix matching + disk scan
   • EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-',
     'transactional-'] — bounds what categories users can create
   • TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ — prevents path traversal
   • isValidTemplateName() validates both regex + prefix membership
   • scanEditableTemplates() returns all matching .html/.mjml files
     currently on disk (excludes .bak-* and .legacy-* variants)
   • listEditableTemplates() now scans disk instead of a static list,
     so newly-created templates appear automatically in the dropdown

3. Enable Unlayer's built-in panels
   • templates: true — exposes Unlayer's template library (limited
     free-tier selection but ~10-20 starters available without a
     projectId)
   • stockImages: true — Unsplash search built into image picker
   • imageEditor: true — basic crop/resize on inserted images
   • undoRedo: true — history navigation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:39:26 -04:00
louispaulb
448e62177e feat(campaigns): convert existing HTML templates to Unlayer JSON designs
Solve the "editor starts blank" problem by writing a one-time converter
that wraps each compiled .html template into a minimal Unlayer design
JSON (one Custom HTML block containing the entire body content). On
next editor load, Unlayer reads .json and renders the template in the
canvas — instant visual fidelity without manual reconstruction.

Strategy choice: Unlayer's "Import HTML" is a Pro-only feature. Building
a real HTML→Unlayer-blocks parser is several days of work. The minimal-
viable conversion (1 row + 1 Custom HTML block) gets the user 90% there
immediately:

  • Canvas shows the template visually (Unlayer renders the HTML)
  • Variables ({{firstname}}, {{gift_url}}, etc.) preserved as text
  • User can edit the HTML directly via the block's side panel
  • User can incrementally REPLACE the HTML block with native Unlayer
    blocks (Text, Image, Button) for any section they want decomposed —
    on their own schedule, not blocking the campaign send

New file: services/targo-hub/scripts/convert-html-to-unlayer.js
  • CLI: node scripts/convert-html-to-unlayer.js <template-name>
  • Reads templates/<name>.html, extracts <body> inner content, detects
    preheader from a hidden <div style="display:none">, builds Unlayer
    design JSON with brand-appropriate body.values (Targo Green link
    color #00C853, Plus Jakarta Sans font, F5FAF7 page background).
  • Backs up existing .json before overwriting.

Generated outputs (committed):
  templates/gift-email-fr.json — 34 KB (30 KB inner HTML + Unlayer chrome)
  templates/gift-email-en.json — 33 KB

Live verification: GET /campaigns/templates/gift-email-fr now returns
{ design: {...Unlayer JSON...} } alongside html. The editor's
onReady() callback in TemplateEditorPage detects data.design and calls
editor.loadDesign(design) → canvas populated immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:22:47 -04:00
louispaulb
4acb18c7df fix(ops/campaigns): drop loadBlank() call + force explicit editor dimensions
Two bugs from the first prod test of the Unlayer editor:

1. `editor.value.loadBlank is not a function` — the loadBlank() method
   exists in newer Unlayer versions but NOT in vue-email-editor 2.2
   which wraps an older Unlayer. When no design is stored yet, just let
   the editor render its default empty state ("No content here. Drag
   content from left.") and show a Quasar notification telling the
   user how to start. No explicit load call needed.

2. Editor renders cramped/small — the EmailEditor component's nested
   iframe doesn't inherit dimensions from Quasar's q-page wrapper.
   Wrap the EmailEditor in an explicit-sized container:
       <div style="height: calc(100vh - 60px); width: 100%; overflow: hidden;">
   Plus pass style="height: 100%; width: 100%" to the EmailEditor itself.
   This gives the editor a full viewport-minus-toolbar canvas.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:18:27 -04:00
louispaulb
9dcd32ef6a feat(ops/campaigns): group merge tags by category + add toolbar hint
Improvements to the variable insertion UX in the Unlayer editor:

1. Reorganized mergeTags from a flat object into 3 logical groups so
   Unlayer's "Merge Tags" dropdown shows them under sub-headers
   instead of a long flat list:

     • Client (firstname, lastname, email, description)
     • Offre (amount, gift_url, expiry, commitment_months)
     • Système (year)

   Format switched from { id: {name, value} } to grouped array
   format (Unlayer accepts both, but groups give better UX).

2. Added `sample` field to each merge tag — Unlayer renders these
   as the visible content while editing, so the canvas shows
   "Louis Tremblay" / "60 $" / "https://gft.link/abc" instead of
   literal "{{firstname}} {{lastname}}". Makes the live preview
   look like real content during edit. Substitution still happens
   server-side at send time via Mustache.

3. New toolbar hint button (code icon, grey) explaining where to
   find merge tags in the Unlayer UI:
   "Insertion : clic dans un texte → barre flottante → icône {}
   Merge Tags. Marche aussi dans les champs URL (boutons, images,
   mailto)."

   This addresses a common discoverability issue: users don't
   always realize variables work in URL fields too (e.g. setting
   a button's "Action URL" to {{gift_url}} so each recipient gets
   their own Giftbit link).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:16:16 -04:00
louispaulb
a11fe5a115 feat(ops/campaigns): pivot template editor to Unlayer (vue-email-editor)
After honest acknowledgment that easy-email-standard is abandoned and
limited (Chrome-only, no responsive preview, no AMP, no Unsplash, no
file manager), pivoted to Unlayer's vue-email-editor — a Vue 3 native
component giving all the features the user listed for free (internal
use; a small "Powered by Unlayer" badge shows in the sidebar but NOT
in sent emails).

Why drop MJML alongside:
  • MJML was our SERVER-SIDE compilation step because we hand-wrote
    templates. With a visual editor that outputs email-safe HTML
    directly (responsive media queries, Outlook MSO fallbacks, AMP
    where used), the compilation step is redundant.
  • One fewer dependency on the hub (mjml package no longer needed).
  • One fewer file format to persist (.mjml dropped, only .html
    canonical + .json design).

Storage simplification:
  Before: .mjml (source) + .html (compiled) + .json (editor state)
  After:  .html (canonical) + .json (Unlayer design tree)

The hub's send-worker reads .html as before — no changes to send
logic.

Architecture wins:
  • Vue 3 native — zero iframe friction, no postMessage choreography
  • No separate microservice — easy-email container decommissioned
    (docker compose down, code kept under /opt/email-editor/ in case
    of rollback)
  • DNS editor.gigafibre.ca retained but unused — can be removed via
    Cloudflare API cleanup later
  • The editor's mergeTags option exposes our {{firstname}}, {{amount}},
    {{gift_url}}, etc. in Unlayer's native "Merge tags" panel — same
    pattern, more polished UI
  • Features now native: responsive preview (mobile/tablet/desktop
    breakpoints), Unsplash search, file manager, dark mode, design
    history, undo/redo, layers panel, content blocks library

Frontend (TemplateEditorPage.vue):
  • Imports EmailEditor from vue-email-editor
  • onReady() callback: fetch template + loadDesign() to restore canvas
  • saveTemplate(): exportHtml() → PUT { html, design } to hub
  • Top bar kept: template selector, saved chip, preview, test-send,
    save button
  • Removed: iframe-related glue (postMessage listener, iframeKey,
    EDITOR_BASE constant, Cmd-S handling that lived in the iframe)

API client (apps/ops/src/api/campaigns.js):
  • saveTemplate() now accepts opts.design (Unlayer JSON tree) alongside
    content. Legacy opts.format='mjml' still works for backward compat.

Hub (services/targo-hub/lib/campaigns.js):
  • GET /campaigns/templates/:name unconditionally returns
    { name, format, html, design } (+ mjml when format=mjml for
    legacy templates). The design field is null when no .json file
    exists yet.
  • PUT /campaigns/templates/:name HTML save path now accepts
    body.design alongside body.html and persists both with backups.
  • MJML save path (legacy) preserved for any callers using the old
    contract.

Container decommissioned on prod: email-editor container stopped +
removed. The Vue editor lives inside the ops SPA, served from
erp.gigafibre.ca/ops as a normal route.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:14:06 -04:00
louispaulb
bb88a27b90 feat(email-editor): persist easy-email JSON state for instant restore on reload
Phase 2.5 — close the load/save loop so the editor isn't broken by a page
refresh.

Problem: easy-email doesn't ship an MJML→JSON parser, so loading an
existing MJML template into the editor canvas isn't possible. First-time
load = empty canvas. Without this fix, every page reload would also reset
to empty (even after saving), making the editor useless past one session.

Solution: persist easy-email's raw JSON tree (editor state) as a third
companion file alongside .mjml + .html. Editor reads .json on load when
present, falls back to empty otherwise.

Three files per template now:
  gift-email-fr.mjml   — MJML source (rendered by send-worker → already done)
  gift-email-fr.html   — compiled HTML (cached output, sent to recipients)
  gift-email-fr.json   — easy-email editor state (UI restoration only)

Backend (lib/campaigns.js):
- New templateJsonPath() helper + EDITABLE_TEMPLATES checks
- GET /campaigns/templates/:name returns { format, mjml, html, json }
  when format=mjml (json null until first easy-email save)
- PUT /campaigns/templates/:name now accepts body.json alongside body.mjml
  (writes both .mjml + .html [compiled] + .json [editor state])
- Backup hook extended to also backup .json before overwrite

Editor (EmailEditorApp.tsx):
- On load: prefer data.json → parse and seed initialValues. If json
  missing but mjml present, show explanatory error banner + empty canvas
  (user reconstructs once; that save fixes future loads).
- On save: send BOTH mjml (compiled via JsonToMjml) AND raw values
  object as json. Hub persists all three artifacts.

First UX flow on next user visit:
  1. Open editor → empty canvas + banner "MJML exists but no JSON
     editor-state yet; reconstruct once to save a JSON snapshot"
  2. User drag-drops blocks to rebuild the template visually
  3. Click save → MJML + HTML + JSON all persist
  4. Subsequent reloads load from JSON instantly with full editor state

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:04:48 -04:00
louispaulb
f9971e9113 feat(ops/campaigns): Phase 2 — switch editor page to easy-email iframe
Replace the broken GrapesJS-mjml integration with an iframe pointing to
the standalone email-editor microservice at editor.gigafibre.ca (created
in Phase 1).

What changed:

- Dropped all grapesjs* imports and ~250 lines of editor init/save/preview
  glue code. That logic now lives in the React app on the other side of
  the iframe.
- Page becomes a thin wrapper:
  • Top bar: back button, template selector, "saved" chip,
    "Aperçu inbox" button, "Envoyer un test" button, reload button.
  • Below: full-height iframe to editor.gigafibre.ca/?name=<template-name>.
- Template switching: bumping iframeKey forces a fresh iframe load so the
  new ?name= param takes effect. Route is updated via router.replace.
- postMessage listener: receives { type: 'email-editor:saved', ts }
  from the editor iframe and shows a positive toast + updates the
  "Sauvegardé · il y a Xs" chip. Origin-checked against EDITOR_BASE.
- Preview dialog: unchanged — fetches compiled HTML from hub's preview
  endpoint and renders in srcdoc iframe.
- Test-send dialog: unchanged from previous version.

Removed (now handled inside the iframe):
- Visual / HTML / Aperçu view-mode toggle (editor.gigafibre.ca handles
  all editing modes natively)
- "Vide" / "Réinitialiser" buttons (editor has its own)
- "Annuler" / "Enregistrer" buttons (editor saves itself on Cmd-S /
  toolbar button)
- spell-check on textarea (editor handles it)
- GrapesJS asset manager wiring (editor will use its own image picker
  in Phase 3)

DNS prerequisite handled separately: editor.gigafibre.ca → 96.125.196.67
created via Cloudflare API (proxied=false to match the existing pattern
that lets Traefik handle Let's Encrypt directly).

Container running on prod via /opt/email-editor/docker-compose.yml,
Traefik routing to Host(`editor.gigafibre.ca`). HTTPS verified live.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:01:28 -04:00
louispaulb
0b6377fa58 feat(email-editor): Phase 1 — scaffold easy-email microservice for visual template editing
GrapesJS-mjml proved broken on our content (plugin v1.0.8 incompatible
with MJML v5 — canvas stays empty on load). Pivot to easy-email, a
mature OSS WYSIWYG email editor (MJML-based, MIT license, 4k stars).

Architecture: standalone React+Vite microservice deployed at
editor.gigafibre.ca, iframed from the ops UI's
/campaigns/templates/:name page. Talks to the hub's existing REST
endpoints (/campaigns/templates/*) for load + save. The hub stays the
source of truth — easy-email is purely the editing UI.

Scaffold delivered in this commit (Phase 1):

- services/email-editor/ — new top-level service directory
- package.json: React 18 + easy-email-{core,editor,extensions} 4.16.x
  + Vite 5 + TypeScript 5
- vite.config.ts: standard dev/build config, port 5173 in dev
- tsconfig.json: strict-false to keep iteration fast
- index.html: loads easy-email CSS bundles from unpkg (extensions, editor,
  arco theme)
- src/main.tsx: React entry, mounts EmailEditorApp on #root
- src/EmailEditorApp.tsx:
  • Reads template name from ?name=... URL param (defaults gift-email-fr)
  • GET ${VITE_HUB_URL}/campaigns/templates/:name on mount
  • Renders <EmailEditorProvider> + <StandardLayout> with our merge tags
    map (firstname, amount, gift_url, description, expiry, etc.) so the
    Variables panel shows our Mustache placeholders
  • On save: JsonToMjml() converts easy-email's JSON → MJML, PUT to hub
    → hub compiles to HTML and persists both files
  • postMessage({type: 'email-editor:saved', ...}) to parent window so
    the iframing ops UI knows to refresh
- Dockerfile: multi-stage (Vite build → nginx alpine serve). SPA fallback
  in nginx config so all routes return index.html.
- docker-compose.yml: container behind Traefik at editor.gigafibre.ca
  with Let's Encrypt TLS via the shared proxy network.
- README.md documents the arch, URL params, postMessage protocol, dev
  workflow, and the Phase 1 limitation (no MJML→JSON importer — editor
  starts from empty page until Phase 3).
- .gitignore: standard node/vite/dist exclusions.

Build verified locally: 83 modules transformed, ~2.8 MB bundle (840 KB
gzipped) — large but acceptable since easy-email packages the full
email builder + drag-drop canvas.

Phase 2 (next): Docker deploy on prod + replace GrapesJS in the ops UI
TemplateEditorPage with an iframe pointing here.
Phase 3 (later): MJML → easy-email JSON parser so existing templates
auto-import into the canvas instead of starting blank.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 23:52:31 -04:00
louispaulb
2fe8d3f50e feat(campaigns/templates): richer 4-block intro (greeting, hook, gift, upsell)
Expanded the email intro from 3 short paragraphs into 4 semantic blocks,
restoring the marketing-friendly "Tu choisis local..." line that earlier
edits had dropped, plus adding new content about the 3.5 Gbit/s plans
and a "we're right around the corner" CTA framing.

FR intro structure now:
  1. "Bonjour {{firstname}},"
  2. "Tu choisis local, on veut te remercier. / Comme toi, on aime les
     connexions stables et les relations durables." (paired manifesto)
  3. "Avec l'arrivée de l'été, voici un cadeau pour toi, disponible
     pour un temps limité."
  4. "Nous offrons maintenant de nouveaux forfaits, jusqu'à 3.5 Gbit/s.
     Que tu souhaites plus de vitesse, battre une autre offre ou juste
     nous jaser, on est juste à côté."

EN translation mirrors the same 4-block structure.

Editorial rationale for block grouping in MJML:
- Each block is its own <mj-text> for independent drag-drop in GrapesJS
- Lines that always travel together (manifesto pair, upsell + CTA pair)
  share one <mj-text> joined with <br/> to reduce component clutter
- Different styles per block (greeting smaller/secondary, manifesto
  larger/bolder, body paragraphs normal) require separate <mj-text>
  components anyway since MJML inherits styling per-block

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:44:16 -04:00
louispaulb
79ae38db60 feat(campaigns): MJML canonical templates + test-send button
Two big moves:

1. Promote MJML to the canonical template format
   - Move gift-email-fr-mjml.{mjml,html} → gift-email-fr.{mjml,html}
   - Create gift-email-en.mjml (English translation of FR MJML)
   - Compile EN MJML → gift-email-en.html
   - Remove obsolete variants:
     • gift-email-fr-simple.html (now replaced by MJML)
     • gift-email-en-simple.html (same)
     • gift-email-fr-mjml.* (renamed to canonical)
   - The old gift-email-fr.html (rich-with-merchant-grid version) is
     backed up as gift-email-fr.legacy-rich.html.bak — kept on disk
     for reference but not in the editable list.
   - EDITABLE_TEMPLATES is now just ['gift-email-fr', 'gift-email-en'],
     both backed by .mjml source + .html auto-compiled output.

2. Add "Envoyer un test" feature
   Backend:
   - POST /campaigns/templates/:name/test-send accepts { to, vars,
     from?, subject? }. Reads compiled .html, renders Mustache vars,
     sends via Mailjet through email.sendEmail with X-MJ-CustomID
     "test-send:<name>:<timestamp>" so webhook events for tests are
     identifiable. Returns { sent, to, from, message_id, bytes }.
   - Default vars are sensible: firstname="Louis", amount="60 $",
     gift_url="https://gft.link/TEST123", etc. User overrides any
     via the request body.

   Frontend (TemplateEditorPage):
   - Toolbar button "Envoyer un test" (orange) — opens a dialog.
   - Dialog has email input + subject + 7 variable inputs
     (firstname, lastname, amount, commitment_months, gift_url,
     description, expiry) with sensible defaults.
   - "Dirty" banner warning: if the user has unsaved changes, the
     test will use the LAST SAVED version (so save first to test the
     latest). Mentions explicitly in card footer.
   - On send: live notification with the message_id + byte count.
     Errors surface clearly.

Verified live in prod:
  POST /campaigns/templates/gift-email-fr/test-send → 200, message_id
  returned, ~32 KB rendered MJML→HTML output, sent from
  TARGO <support@targointernet.com> (Mailjet-validated sender).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:36:35 -04:00
louispaulb
b37270c11d feat(campaigns/editor): MJML mode — proper email-focused visual builder
Pivot the template editor toward email-marketing-grade visual editing
by replacing grapesjs-preset-newsletter (permissive HTML, fails to parse
nested table structures) with grapesjs-mjml (the industry-standard
email markup language used by Mailchimp/Sendgrid/Twilio).

Why MJML: it was specifically designed to solve the "visual editor +
email-safe HTML" problem. You write semantic <mj-section>, <mj-column>,
<mj-button>, <mj-image> components — MJML compiles them to the gnarly
email-safe HTML with Outlook fallbacks + responsive media queries
auto-generated. Source is 3x more compact than hand-written HTML and
parses cleanly in visual editors.

Backend (lib/campaigns.js):

- Add `mjml` (v5, async) dependency. Compilation happens server-side
  at SAVE time only; the send-worker reads pre-compiled .html (no
  per-recipient compile cost).
- Each template can now be in 'mjml' or 'html' format. Detection by
  file extension on disk: .mjml present → format='mjml', otherwise
  format='html'. Source of truth for MJML templates = .mjml file;
  .html is the auto-compiled output kept alongside for the send-worker.
- GET /campaigns/templates → returns { name, format, size } per template.
- GET /campaigns/templates/:name → returns { format, mjml?, html }
  (mjml field present only when format=mjml; html always present).
- PUT /campaigns/templates/:name accepts:
    { mjml: "<mjml>..." }  → compile to HTML, save both .mjml + .html
    { html: "..." }        → save .html only (legacy path, unchanged)
  Compilation errors return 400 with details (MJML validation soft mode).
  Both files backed up as .bak-<ts>.<ext> before overwrite.

Frontend (TemplateEditorPage.vue):

- Detect format from API response on load.
- For format='mjml': swap grapesjs-preset-newsletter for grapesjs-mjml
  plugin. Editor's getHtml() returns MJML source (not compiled HTML);
  Save POSTs the MJML, hub compiles + persists both files.
- For format='html': existing behavior unchanged.
- Editor is destroyed + reinitialized when format changes (different
  plugin sets).
- Custom variable blocks ({{firstname}}, {{amount}}, etc.) work for
  both formats — they're text content, format-agnostic.

API client (apps/ops/src/api/campaigns.js):

- saveTemplate(name, content, { format }) routes to the right PUT body
  shape based on format param.

Prototype: gift-email-fr-mjml — full MJML conversion of the simple
variant, ~7.5 KB MJML source compiling to ~32 KB email-safe HTML with
0 validation errors. All 6 Mustache variables preserved through
compilation (firstname, amount, gift_url, description, commitment_months,
year). User compares the MJML editor experience to the existing HTML
templates and decides whether to migrate the others.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:29:42 -04:00
louispaulb
1af8b3a029 feat(campaigns/templates): add gift-email-{fr,en}-simple variants
Flat single-table-per-section structure (max 1 level of nesting) so that
GrapesJS' preset-newsletter parser can recognize each section as an
editable component. Same brand visuals + content as the rich variants,
but: dropped the 12-logo merchant grid (heaviest part for the editor),
compacted the three info pills into one consolidated card.

Sections (top-level <table width="600">):
  1. Header logo
  2. Greeting + brand-line + offer intro
  3. Compact info card (was 3 pills)
  4. Option 1 chip
  5. Big green CTA button
  6. Prorata refund disclaimer
  7. Option 2 chip + text
  8. Optional expiry notice (Mustache conditional)
  9. Signature
 10. Contact info (outside card)
 11. Dark footer band (logo + address + copyright)

Each section is a standalone <table role="presentation" width="600">
sharing the same #ffffff background. The first and last get the rounded
border-radius, middle sections have no rounding. Result: visually one
unified card, structurally many editable blocks.

Registered both new variants in EDITABLE_TEMPLATES whitelist so the
ops UI editor picks them up. Rich variants gift-email-fr.html and
gift-email-en.html are unchanged — both styles coexist. User compares
in the editor and picks which to standardize on per campaign.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:04:39 -04:00
louispaulb
bbd2b31761 feat(campaigns/templates): new opening line + logo image in dark footer
Per user feedback after seeing the rendered preview:

1. Opening line replaced:
   FR: "Tu choisis local, on veut te remercier." →
       "Comme toi, on aime les connexions stables et les relations durables."
   EN: "You went local — we want to say thanks." →
       "Just like you, we love stable connections and lasting relationships."
   The new line ties the Internet service (stable connections) to the
   relationship framing (lasting), which reads more naturally than the
   previous "we want to thank you" phrasing.

2. Dark footer band cleanup:
   • Removed the CSS-styled TARGO. wordmark (with green dot)
   • Removed the official slogan line "Services de confiance, ..."
   • Replaced with the actual TARGO logo image (img tag at 120px wide)
   The wordmark is now ALWAYS the logo image, never a text styling —
   keeps the brand mark consistent across header and footer.

TODO marker left in the HTML pointing to the white-variant logo: the
brand guide §1 specifies targo-logo-white.svg for dark backgrounds, but
we only have the green variant uploaded on Mailjet (UUID eed4d18c-...).
The green logo on the #1C1E26 Targo Dark bg is readable but not
pixel-perfect with the brand. To fix, upload the white variant via the
new /campaigns/assets/upload endpoint and swap the src in both
templates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:56:17 -04:00
louispaulb
4a4d145465 feat(campaigns/assets): self-hosted image upload + GrapesJS asset manager
Background: existing Mailjet-hosted brand logos in the gift email templates
stay as-is — those URLs are stable and live on Mailjet's CDN. This change
adds infrastructure for ADDITIONAL images the user wants to drop into the
editor going forward (event photos, custom illustrations, technician
photos for service campaigns, etc.) without uploading to Mailjet first.

Why self-hosted: avoids vendor lock-in for new assets, gives us control
over retention + immutable URLs, integrates natively with our GrapesJS
editor's AssetManager. The cost is ~5 MB max per image and one new bind
mount on the hub.

Backend (lib/campaigns.js):

- Storage at services/targo-hub/uploads/ (new bind mount, RW, mounted into
  the container at /app/uploads). Files named by SHA-256 of content for:
  • Automatic dedup (same image twice → same URL, no extra disk)
  • Immutable URLs (content never changes for a given filename)
  • Path-traversal defence (regex-locked filename pattern)

- POST /campaigns/assets/upload — accepts JSON { name, data } where data
  is a data:image/...;base64,... URL. Decodes, validates MIME against
  allow-list (png/jpg/gif/webp/svg), enforces 5 MB cap, hashes, persists,
  returns { url, filename, size, content_type, data: [...] }. The `data`
  array shape matches what GrapesJS' AssetManager expects on upload
  success. Using base64-in-JSON avoids pulling a multipart parser
  dependency — the ~33% encoding overhead is fine for ≤5 MB images.

- GET /campaigns/assets — list all uploaded assets with metadata
  (filename, url, size, modified, content_type).

- GET /campaigns/assets/:hash.<ext> — serve image bytes with
  Content-Type matching the extension + Cache-Control:
  public, max-age=31536000, immutable. The 1-year cache is safe because
  filename = content hash → URL never serves different bytes. Aligns
  with how Gmail's image proxy and Outlook's caching work.

- DELETE /campaigns/assets/:hash.<ext> — admin removal from disk.

- Helpers (persistUpload / readUpload / deleteUpload) live at module
  scope so they can call `path.join` (otherwise shadowed by the `path`
  URL parameter inside handle()).

API client (apps/ops/src/api/campaigns.js):

- listAssets()  → GET /campaigns/assets
- uploadAsset(file) → reads file via FileReader, posts base64 JSON
- deleteAsset(filename) → DELETE the hash-named file

GrapesJS editor (TemplateEditorPage.vue):

- assetManager config with custom uploadFile callback that bypasses
  GrapesJS' built-in multipart uploader. Drag-drop or file-picker
  triggers our base64 upload, on success the URL is added to the
  AssetManager library so it appears in the editor sidebar for reuse.

- onMounted: preload all previously-uploaded assets via listAssets()
  so the user sees their image library immediately when opening the
  editor (no need to re-upload images used in past campaigns).

End-to-end verified live in prod:
  POST /campaigns/assets/upload   → 200 (with data URL JSON body)
  GET  /campaigns/assets          → 200 (list)
  GET  /campaigns/assets/:hash    → 200 (serves PNG bytes)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:53:01 -04:00
louispaulb
d694d889a1 feat(campaigns/templates): replace placehold.co with real Mailjet logos for rows 2-3
User pasted the full HTML block from their Mailjet Passport editor —
extracted the 8 missing CDN URLs for the merchant grid bottom rows and
swapped them into both FR and EN templates.

Final 12-logo grid is now 100% real Mailjet-hosted assets matching the
user's brand-approved visuals (no more placehold.co rectangles):

  Row 1: Amazon, IGA, Tim Hortons, $1 Plus           (already real)
  Row 2: Pizza Pizza, Home Depot, Best Buy, Walmart  (NEW)
  Row 3: Petro-Canada, Esso, Home Hardware, Sobeys   (NEW)

URL pattern: https://xqy3m.mjt.lu/img2/xqy3m/<UUID>/content
Width normalized to 95px (consistent with row 1) instead of the source
template's 300px since our 600px-wide email card means each 25% column
is ~140px effective — 95px image fits with proper margins.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:36:51 -04:00
louispaulb
d897bcedb4 feat(campaigns): auto-clean first/last names (QC accents + compound split)
The Map CSV migrated from the legacy ERP carries names with two common
defects: missing French accents (Stephane, Andre, Frederic), and
compound first names that were typed without a separator (Marcandre,
Mariejosee, Jeanphilippe). Sending an email "Bonjour stephane," instead
of "Bonjour Stéphane," reads as sloppy automation. Fix both at parse
time so the user sees the corrected names in Step 2 and can override
inline if the auto-cleaner got it wrong.

Backend (lib/campaigns.js):

- FR_NAME_FIXES — 100+ entry dictionary mapping lowercase no-accent
  Québec first names to their canonical accented form (André, Stéphane,
  Frédéric, Geneviève, Hélène, Joséée, etc.). Sourced from MIQ baby
  names + older-generation curation.

- COMPOUND_PARTS — list of common name parts (jean, marie, anne, marc,
  philippe, françois, etc.) that combine into QC compound first names.
  When two parts appear concatenated with no separator, the cleaner
  splits and hyphenates them. Example: "Marcandre" → ["marc","andre"]
  → "Marc-André" (dictionary then applies accent).

- titleCaseToken — proper Title Case respecting apostrophes (O'Brien,
  L'Heureux) and hyphens (Marie-Ève). Uses \p{L} Unicode class so it
  works on accented chars correctly.

- cleanName(raw) — full pipeline: trim → Title Case → dictionary
  lookup per word → compound split fallback. Applied to firstname AND
  lastname in parseMapCsv.

- nameWarning(name) — heuristic flag for cases the cleaner couldn't
  confidently handle: digit in name, single letter, abnormally long
  without separator (likely two stuck names not in COMPOUND_PARTS).
  Returns a short FR description for the UI tooltip.

- parseMapCsv now returns firstname/lastname (cleaned) + firstname_raw/
  lastname_raw (original from CSV) + cleaned_changed bool + name_warnings
  per recipient. UI uses these to show before/after + flags.

UI (CampaignNewPage Step 2):

- New counter card "Noms à vérifier" — count of recipients with at least
  one nameWarning. Only renders if > 0.

- Info banner above the recipients table:
  "X nom(s) auto-corrigés (...)  Y nom(s) suspects (...)"

- Per-row icons in the firstname + lastname columns:
  • ⚠ amber WARNING — cleaner flagged this name as suspicious
    (tooltip shows the reason: "deux prénoms collés", "contient un
    chiffre", etc.)
  •  green AUTO_FIX_HIGH — auto-cleaner changed something at parse
    time (tooltip shows the original raw value)
  Both icons are tooltip-only — no action required.

- Click any name cell → q-popup-edit opens an inline input. Type the
  correction, Enter saves. ESC cancels. This is the manual override
  path for any name the auto-cleaner mishandled.

Tests (manual via end-to-end smoke against prod):
  STEPHANE TREMBLAY     → Stéphane Tremblay     ✓ accent + Title Case
  marie tremblay        → Marie Tremblay        ✓ Title Case only
  Marcandre Boileau     → Marc-André Boileau    ✓ compound + accent
  Jean Francois Lebrun  → Jean François Lebrun  ✓ accent only
  Mariejosee Lapierre   → Marie-Josée Lapierre  ✓ compound + double accent
  Andre LAPRISE         → André Laprise         ✓ both fixed
  Helene St-Pierre      → Hélène St-Pierre      ✓ accent, hyphen preserved
  Frederic O'Brien      → Frédéric O'Brien      ✓ accent, apostrophe preserved

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:33:10 -04:00
louispaulb
2b85735006 fix(ops/campaigns): clarify Step 2 actions + add inline preview + jump-to-editor
User confusion: the "Approuver — 3 à envoyer" button at the end of Step 2
had a send icon, suggesting it fired emails immediately. It actually
just navigated to Step 3 (the confirmation step). The current flow has
two consent moments (Step 2 approve → Step 3 launch) but the UI made
them look like one.

Three changes to address this:

1. Step 2 navigation button:
   - Icon changed from 'send' to 'arrow_forward' — clearly "next step"
   - Label changed from "Approuver — N à envoyer" to "Continuer — N prêts"
   - Added tooltip explaining the send only happens at Step 3

2. Inline preview dialog:
   - New "Aperçu du courriel" button in Step 2 (and Step 3)
   - Opens a maximized dialog with an iframe rendering the actual template
     via POST /campaigns/templates/:name/preview, using the first sendable
     recipient's real data + the campaign params (amount, expiry, etc.)
   - FR/EN toggle inside the dialog so the user can verify both templates
     before launching a mixed-language campaign
   - Defaults to the recipient's own language for first view
   - Non-destructive — fires zero emails

3. Always-accessible "Éditer le template" link:
   - Persistent button in the page header (visible all 3 steps)
   - Plus secondary buttons in Step 2 + Step 3 action rows
   - Opens the template editor in a NEW TAB so the wizard's state
     (uploaded CSVs, parsed recipients) stays intact in the original
     tab — the user can tweak the template, save, switch back, click
     "Aperçu" to see the change, then continue with the send

4. Step 3 confirmation hardening:
   - Banner color escalated from amber to red (this IS the point of no
     return for actual delivery)
   - Wrap the launch button click in a Quasar confirm dialog ("Envoyer
     N courriel(s) maintenant ? Pas annulable.") — adds a third friction
     point against accidental clicks
   - Launch button is red (negative) — visually distinct from the green
     navigation primaries to signal "destructive action ahead"
   - Back-to-Step 2 button renamed "Retour modifier" with arrow_back
     icon for clarity

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:01:40 -04:00
louispaulb
d6096fe1f8 feat(campaigns): apply real TARGO brand + auto-route FR/EN by Customer.language
Brand audit against the official guide (Feb 2026 v1.0) caught several
inconsistencies in the email template:

- Wrong primary green: was #019547, should be #00C853 (Targo Green from
  brand palette). Globally replaced.
- Wrong gradient: was #019547→#06a04d, should be 135deg #00C853→#005026
  (the official Gradient Targo from the brand). Now using Outlook-safe
  background-image + bgcolor fallback for solid green on Outlook desktop.
- Wrong contact info: facturation@targointernet.com / 514 242-1500 →
  support@targo.ca / 514 448-0773 / 1 855 888-2746 (per §11 of guide).
- Wrong website: targointernet.com + gigafibre.ca → www.targo.ca.
- Missing slogan + green dot: footer now ends with the trademark
  tagline "Services de confiance, tout-en-un, près de chez vous." with
  the obligatory green period (always FR — it's the trademark, not a
  marketing line, so stays untranslated in EN template too).
- Missing brand fonts: added Space Grotesk (display) + Plus Jakarta
  Sans (body) via Google Fonts. Wrapped in MSO conditional comments so
  Outlook desktop skips the request and falls back to Helvetica via
  the explicit font-family stack on every element.
- Wrong body bg / text colors: now #F5FAF7 (Muted) / #1B2E24
  (Foreground) per brand semantic palette.
- Wrong info-pill bg: was #f3f4f3 → #F5FAF7 (Muted).
- Added official dark footer band #1C1E26 (Targo Dark) with white
  inverted wordmark, slogan, address, copyright.

Multilang routing (FR/EN):

- lib/campaigns.js matchCustomer now fetches Customer.language
  (14k FR / 1k EN distribution confirmed on prod). Default 'fr' for
  unmatched contacts.
- New templateForLanguage(lang) helper picks gift-email-<lang>.html,
  falls back to FR. Resolves 'fr-CA' → 'fr' etc.
- sendCampaignAsync pre-loads templates per recipient with an in-memory
  cache to avoid re-reading from disk on every send.
- gift-email-en.html created — English translation of the full FR
  template, keeping the slogan in French (it's the trademark tagline).
- year variable now injected (replaces hardcoded © year).

UI (CampaignNewPage):

- New "Langue" column in the Step 2 recipient table. Shows a clickable
  chip (FR primary green / EN blue-grey) that toggles language inline,
  so a campaign manager can override the ERPNext-resolved language
  per recipient.
- Step 3 recap now shows "Répartition par langue: 145 × FR, 12 × EN"
  before confirming the send.

Spell-check:

- TemplateEditorPage HTML mode now has spellcheck="true" + dynamic
  lang attribute on the textarea, picked from the template name suffix
  (gift-email-fr → fr, gift-email-en → en). Browser's native dictionary
  flags typos in real time. AI-grade rewrites deferred to the future
  /campaigns/ai/rewrite endpoint discussed previously.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:50:56 -04:00
louispaulb
8d9e190c21 feat(ops/campaigns): explicit contact↔shortlink pairing review before approve
Step 2 of the new-campaign wizard previously dropped unpaired contacts
silently (Math.min(contacts, gifts) iteration) — if you uploaded 5
contacts and 3 gift links, you got 3 recipients in the table with no
visible signal that 2 contacts were left out. Step 1 only showed
"contacts skipped: N" in a small banner, easy to miss.

Surface the imbalance explicitly so the user can decide before sending:

Backend (POST /campaigns/parse):
- Return unpaired_contacts[] and unused_gifts[] arrays (with row_index
  for source-CSV cross-reference), in addition to the existing
  recipients[]. Old leftover_gifts / leftover_contacts counters kept
  for backward compat.

UI (CampaignNewPage Step 2):
- New columns in the recipients table:
  • # (row index from the source CSVs)
  • Lien-cadeau (truncated shortlink, clickable to verify)
  These let the user eyeball the contact↔link pairing line by line.
- New counter strip:
  Paires / À envoyer / Client lié / Sans client / Sans lien / Liens surplus
- "Sans lien" and "Liens surplus" counters appear only when relevant.
- Explicit warning banner explaining what unpaired/unused means
  (acquire more links and re-upload, or proceed knowing N won't get).
- Expansion panel listing each unpaired contact with their row_index +
  details, so the user can verify which specific contacts will be
  excluded before approving.
- Expansion panel listing each unused gift URL (extra capacity).
- "Approuver" button now shows the exact send count: "Approuver — N à
  envoyer". Disabled when 0. Step 3 recap also reflects sendableCount.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:31:44 -04:00
louispaulb
0186a7318e fix(ops/campaigns): correct row counts in Step 1 — Link Order CSV had no header
The Step 1 file-upload widgets displayed `(newlines) - 1` for both CSVs,
assuming both files have a header row to discount. This breaks for the
Giftbit Link Order export which is headerless (one URL per line): a
3-URL file was showing "2 cartes-cadeaux" because the parser ate URL #1
as a fake header.

The backend parser was already correct (detects Link Order vs Campaign
format by inspecting the first line). The bug was UI-only — the count
display reused the same arithmetic for both formats.

Fix: introduce countMapRows / countGiftRows helpers that mirror the
backend's format detection. Map CSV subtracts 2 (preamble + header).
Gift CSV subtracts 0 for Link Order (headerless) or 1 for Campaign
export (with header). Plus a "(format: Link Order)" hint next to the
count so the user sees which detection path was taken.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:27:22 -04:00
louispaulb
ff629a6a85 feat(campaigns): support Giftbit Link Order CSV + add blank-canvas editor mode
Two issues spotted during first real-data test:

1. parseGiftbitCsv only handled the Campaign-export format (header row
   + columns firstname/lastname/email/gift_url/uuid/...). The Link Order
   product Giftbit ships when you pre-buy N links exports a different
   format: headerless, one URL per line. Detect this by checking the
   first non-empty line: if it starts with http(s):// and has no
   comma/pipe/tab separators, treat the whole file as bare URLs. Each
   URL maps to one recipient (row-order matching, same as before).

2. The template editor was hard-coded to load the existing
   gift-email-fr.html into GrapesJS on mount. Hand-crafted email HTML
   with deeply nested tables doesn't parse cleanly into GrapesJS
   components, so the visual canvas often renders blank. Two new
   toolbar actions to address this:

   • "Vide" — clears the canvas to a minimal table-based skeleton.
     For composing brand-new templates from scratch in the visual
     editor without inheriting the existing template's structure.
     Confirms before resetting, then sets dirty=true so the next Save
     overwrites the on-disk template (with hub-side backup).

   • "Réinitialiser" — reloads the last on-disk version, discarding
     any unsaved canvas state. Confirms if dirty.

   Plus an amber banner in visual mode (auto-hidden when blank-canvas
   is active) explaining that Visual mode is for new templates and
   the existing template should be edited in HTML mode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:14:29 -04:00
louispaulb
0f78fbe27e fix(hub/campaigns): move /templates routes above the /:id wildcard
The /campaigns/:id GET handler uses a wildcard regex /^\/campaigns\/([^/]+)$/
which captures "templates" as a fake campaign id and returns 404 before the
fixed /campaigns/templates routes get a chance to match.

Reorder the handle() chain so the fixed paths (/templates, /webhook) come
first, then the wildcard :id routes. Add a comment block calling out the
ordering requirement so future endpoints don't reintroduce the bug.

Verified live: GET /campaigns/templates returns the editable list,
GET /campaigns/templates/gift-email-fr still returns the HTML.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:15:04 -04:00
louispaulb
611f4ed5a6 feat(ops/campaigns): UI module for gift campaigns + GrapesJS template editor
New /campaigns section in the ops SPA, gated by manage_users (proxy until a
dedicated manage_campaigns capability is added).

Pages (apps/ops/src/modules/campaigns/pages/):

- CampaignsListPage: table of all campaigns with status chip + progress
  bar (sent/total, with fail count), "Nouvelle campagne" + "Éditer le
  template" buttons. Empty state with onboarding copy.

- CampaignNewPage: 3-step Quasar Stepper wizard.
  Step 1 — upload Map CSV + Giftbit CSV, configure params (name, amount,
           commitment_months, sender, throttle, multi-email handling).
  Step 2 — preview the matched send list from POST /campaigns/parse, with
           counters (matched/unmatched/excluded), per-row match-method
           chip, and exclude/include toggle. Banner warns when CSVs are
           mis-aligned (leftover gifts or contacts).
  Step 3 — confirmation recap with estimated send duration, then fire
           POST /campaigns + POST /campaigns/:id/send and redirect to the
           live detail page.

- CampaignDetailPage: per-recipient table with status chips updated live
  via EventSource on the campaign:<id> SSE topic. Counters bar
  (envoyés / cliqués / queued / échecs / non envoyés), progress bar,
  per-row customer-link badge with deep-link into /clients/<id>.
  Auto-subscribes to SSE when status is draft|sending; "Lancer l'envoi"
  button for draft campaigns.

- TemplateEditorPage: GrapesJS-based visual editor for the campaign
  templates. Three view modes (Visuel / HTML / Aperçu) — the HTML mode
  is the fallback for our table-heavy hand-crafted template that
  GrapesJS-preset-newsletter may parse imperfectly. Aperçu mode calls
  POST /campaigns/templates/:name/preview on the hub for live variable
  substitution. Custom GrapesJS blocks under "Variables" category for
  drag-drop insertion of {{firstname}}, {{amount}}, {{gift_url}},
  {{description}}, {{expiry}}, {{commitment_months}}. Saves via PUT
  with hub-side backup of the previous version.

Wiring:

- api/campaigns.js: hubFetch wrapper, exports parseCsvs / createCampaign
  / listCampaigns / getCampaign / updateCampaign / sendCampaign +
  campaignSseUrl(id) for EventSource subscription, + listTemplates /
  getTemplate / saveTemplate / previewTemplate for the editor.

- router/index.js: three new routes under /campaigns. The
  /campaigns/templates/:name? route is positioned ABOVE /campaigns/:id
  to prevent the wildcard from catching template paths.

- config/nav.js + layouts/MainLayout.vue: "Campagnes" sidebar entry with
  Lucide Gift icon.

- package.json: grapesjs + grapesjs-preset-newsletter dependencies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:08:04 -04:00
louispaulb
5d763f12ff feat(hub): gift-campaign module — CSV parse, customer match, async send + webhook
- lib/campaigns.js (new): full backend for the gift campaign flow.
  • Two CSV parsers: parseMapCsv handles the pipe-delimited legacy export
    with title preamble; parseGiftbitCsv auto-detects the URL column.
  • Multi-strategy customer match against ERPNext: email → phone → civic
    + postal_code on Service Location. Returns confidence score (1.0 /
    0.9 / 0.8) and match method. Addresses the 25%-match limitation of
    the legacy_delivery_id approach by fanning out to address-based
    lookup when email/phone miss.
  • Storage: JSON files at data/campaigns/<id>.json with embedded
    recipients array. Counters auto-recomputed from recipient statuses
    on every save (single source of truth).
  • Async send worker: setImmediate fire-and-forget loop, throttle
    configurable, broadcasts recipient-update events over SSE topic
    campaign:<id> for live UI progress.
  • Mailjet webhook handler at POST /campaigns/webhook: matches events
    to recipients via X-MJ-CustomID = "<campaign-id>:<recipient-index>"
    for O(1) lookup, falls back to MessageID scan if CustomID absent.
  • Template CRUD endpoints (GET/PUT /campaigns/templates/:name) with
    automatic timestamped backups before overwrite. Path-traversal
    guarded by an allow-list (only gift-email-fr editable).
  • Mustache section renderer ({{#var}}...{{/var}}) shared with the CLI.

- lib/email.js: accept opts.from override (campaign sender differs from
  default MAIL_FROM) and opts.headers passthrough (needed for the
  X-MJ-CustomID header that drives webhook → recipient correlation).
  Return the nodemailer info object on success instead of a bare bool so
  callers can capture info.messageId — legacy truthy checks still work.

- server.js: register /campaigns/* route on the hub router.

- templates/gift-email-fr.html: bundled copy of the campaign template
  inside the hub so it's deployable without scripts/ on the path. Kept
  in sync manually with scripts/campaigns/templates/gift-email-fr.html.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:07:40 -04:00
louispaulb
9f2b37939d feat(campaigns): TARGO rebrand + Mustache sections + Mailjet webhook setup
- Template gift-email-fr.html: switch from Gigafibre indigo to TARGO green
  (#019547), use real Mailjet-hosted TARGO logo, adopt retention-offer
  layout from the latest mockup (tutoiement, Option 1/Option 2 split,
  prorata-refund disclaimer, "L'équipe TARGO" signature). Row 1 of the
  merchant grid uses real Mailjet logos (Amazon, IGA, Tim Hortons, $1
  Plus); rows 2-3 are placehold.co until URLs are shared.

- send_gift_campaign.js: add {{#var}}...{{/var}} Mustache section support
  to the renderer so the optional expiry block disappears cleanly when
  --expiry is omitted (was rendering literal tags before). Add new
  --commitment-months CLI flag (default 3) for the "Rester encore X mois
  ou +" wording.

- setup_mailjet_webhook.js (new): one-shot Node script to register the
  Hub callback URL with Mailjet's /v3/REST/eventcallbackurl. Defaults
  to a safe event subset (open/click/spam/unsub) that doesn't conflict
  with the WP-Mail-SMTP integration already owning sent/bounce/blocked.
  --all forces full takeover with a conflict guard requiring
  --force-takeover to overwrite existing records. Supports --list and
  --delete for inspection / rollback.

- package.json (new): nodemailer dependency for SMTP send.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:07:20 -04:00
louispaulb
9b06e2df30 fix(docs/campaigns): support@targointernet.com is the validated sender, not support@targo.ca
Previous commit (380f3bc) incorrectly claimed Mailjet verified targo.ca
at the domain level. It doesn't — Mailjet validates senders ONE BY ONE,
even when SPF/DKIM/DMARC are correctly published at the domain. The
mistake: SMTP returned `250 OK` on a send-test from support@targo.ca,
but the message was silently dropped on Mailjet's side because that
specific mailbox hadn't been approved.

Validated senders on this Mailjet account:
  ✓ noreply@targo.ca           — hub transactional (invoices etc.)
  ✓ support@targointernet.com  — gift campaigns

`support@targo.ca` is NOT validated, despite being on a domain whose
sibling (`noreply@targo.ca`) is.

Updated:
  - README default --from value
  - The "sender" section now explains per-sender validation (not
    domain-level) and the SMTP-250-but-not-delivered gotcha
  - Listed both validated senders explicitly with usage intent

The script itself (send_gift_campaign.js) was already
sender-agnostic — only README guidance changed. New senders are added
in Mailjet console → Sender Domain Verification → Add sender, with
the verification link mailed to the new address.
2026-05-21 16:42:18 -04:00
louispaulb
380f3bc0e7 docs(campaigns): document support@targo.ca as the default gift-campaign sender
Validated live with Mailjet: targo.ca is verified at the DOMAIN level
(SPF + DKIM + DMARC published in Cloudflare), so any *@targo.ca sender
works without per-mailbox approval. Tested 1 send from
support@targo.ca → accepted, delivered.

Why support@ rather than noreply@ for campaigns:
  - Campaigns INVITE a reply (questions about the gift, "I didn't get
    mine", "the link doesn't work", etc.)
  - noreply@ is for transactional system mail where there's nothing
    useful for a human to reply to
  - Different intent → different sender

The hub's transactional emails (invoices, magic links) continue to
use noreply@targo.ca; campaigns specifically use support@targo.ca.
README updated accordingly with the rationale.

Note for future: if we ever want a @gigafibre.ca sender, that's
~30 min of Mailjet setup (add domain, publish SPF/DKIM CNAMEs in
Cloudflare). Not done today because all customer-facing email
flows through targo.ca and support@ is the right mailbox for this
campaign intent.
2026-05-21 16:36:06 -04:00
louispaulb
e1283f30e8 feat(campaigns): add Giftbit API client + validate end-to-end with sandbox
Adds create_giftbit_campaign.js — Node CLI that POSTs to the Giftbit
API (testbed or production), creates a campaign with
delivery_type=SHORTLINK so Giftbit does NOT send their own English
template emails, polls /gifts?campaign_uuid=... until the redemption
shortlinks are generated, then writes a gifts CSV ready to feed into
send_gift_campaign.js.

Two non-obvious things learned while wiring it up:

1. The right endpoint to get the shortlinks is /gifts (not /links).
   /links/{uuid} returned 0 rows on our sandbox account; /gifts has
   a `shortlink` field on each gift once delivery_status transitions
   from QUEUED → LINKCREATED. Polled with 2s interval, up to 20 tries.

2. delivery_type=SHORTLINK is mandatory. Default is GIFTBIT_EMAIL,
   which fires their English template immediately — defeating the
   whole point of bridging through our French Mailjet template.
   Confirmed in the campaign GET response that delivery_type echoes
   back correctly when we send "SHORTLINK".

Validated end-to-end (entirely synthetic data — Alice/Bob/Charlie at
@example.com, no real customer info in the sandbox):
  ✓ Auth probe via /ping returns 200
  ✓ POST /campaign returns campaign UUID
  ✓ After ~12s, /gifts returns 3 gifts each with a working shortlink
  ✓ send_gift_campaign.js consumes the gifts CSV + the contacts CSV
  ✓ FR template renders: "Bonjour Alice", http://gtbt.co/7TKGFDBNVZq
    embedded in the CTA button href, address in the footer line

The --sandbox flag does double duty: routes the API to
api-testbed.giftbit.com AND replaces every recipient email with
louis@targo.ca so we can't accidentally hit real customer inboxes
with the non-redeemable test gifts.

README updated with the two-stage pipeline (create → send), explicit
warnings about the customer-matching gap (only 25% of source rows
resolve via legacy_delivery_id — the rest use a different ID space
from the source Map tool), and the sandbox-quirk where Giftbit
collapses recipient_name when emails are duplicated.

Token NOT committed — pulled from GIFTBIT_TOKEN env var per the
script's contract. In production we'll store it in the hub's
.env alongside SMTP_USER / SMTP_PASS.
2026-05-21 16:20:28 -04:00
louispaulb
37896421c3 feat(campaigns): MVP gift campaign sender (Node CLI + FR email template)
User context: needs to send Giftbit gift cards to 203 customers with a
branded French email instead of Giftbit's English-only default delivery.
Giftbit's own UI/API can issue the gifts but its email is English; this
MVP bridges the gap by taking the gift URLs back from Giftbit, pairing
them with our contact CSV, and sending personalized FR emails through
the Mailjet SMTP that's already wired up for ERPNext invoice mail.

Three files in scripts/campaigns/:

1. send_gift_campaign.js — Node CLI. Two CSV inputs (gifts + contacts),
   matches by row order (default) or email key, renders the HTML
   template with mustache-style {{firstname}} / {{gift_url}} / etc.,
   sends via nodemailer with configurable SMTP + throttle.
   --dry-run writes per-recipient previews to disk for visual review
   before flipping to live mode. Results CSV with per-row status
   (sent / failed / dry-run) + error message + timestamp is written
   next to the script for follow-up on failures.

2. templates/gift-email-fr.html — branded French email. Table-based
   layout (the only thing that renders consistently in Gmail / Outlook /
   iOS Mail / Apple Mail / Bell Sympatico). Indigo gradient header,
   centered CTA button, contextual {{description}} line citing the
   service address, support contact in the footer, no inline images
   (defers to text + colour blocks to dodge image-blocking).

3. contacts_from_legacy.py — replaces the ad-hoc /tmp Python I ran
   earlier with a proper repo'd version. Same multi-email handling
   options (first / split / skip) as I offered the user; defaults to
   "first" = 1 gift per household, which is what they chose. Title-
   cases the address with French article rules (de / du / la / aux
   stay lowercase, 1re / 2e ordinals stay lowercase too).

4. README.md — end-to-end usage with the actual SMTP env vars from
   /opt/targo-hub/.env and the matching strategy decision matrix.

Validated end-to-end with a 5-row dry run: matching works, accents
preserved (Amélie, Geneviève, Marc-André), {{firstname}} interpolates,
gift URLs land in the rendered button href, address shows in the
contextual footer line. Previews written to disk for visual QA.

NOT in this MVP (out of scope, can come next if we end up running
gift campaigns regularly):
  - No persistence to ERPNext doctype (no Gift Campaign / Recipient
    records — pure CLI, results CSV is the audit trail)
  - No click-tracking redirect (the gift_url goes verbatim to the
    recipient; Giftbit's own API/dashboard reports redemption status,
    which is the more relevant signal than "clicked the link")
  - No ops UI page (CLI is fine for one-shot; if this becomes regular
    we wrap it in services/targo-hub/lib/gift-campaign.js + a Vue page)
2026-05-21 15:51:01 -04:00
louispaulb
a6974e2443 chore(ops): install frappe_pg + version-control the post-install patch
Done what the docs suggested in c31a9e0 — actually installed
excel-azmin/frappe_pg on prod erp.gigafibre.ca (1.0.0, master pinned
at commit a237f5995b). Hit one compat bug along the way and fixed it.

The bug

  frappe_pg monkey-patches PostgresDatabase.commit() and .rollback()
  with wrappers that have the OLD `(self)` signature. Frappe 16.12+
  now calls `db.rollback(chain=True)` from app.py:sync_database(),
  which makes every HTTP request crash with:

    TypeError: patched_rollback() got an unexpected keyword argument 'chain'

  Symptom: HTTP 500 on /api/method/ping, Sales Invoice list, etc.
  Customer Server Scripts that don't return through sync_database
  (like our customer_balance) still worked, which is why the bug
  only surfaced after the post-install restart.

The fix

  Two-part: signatures take `*args, **kwargs`, and the wrapped call
  forwards them to the original. Idempotent sed.

    -def patched_rollback(self):
    +def patched_rollback(self, *args, **kwargs):
    -    return _original_rollback(self)
    +    return _original_rollback(self, *args, **kwargs)

  Both files in frappe_pg need it: postgres/database_patches.py and
  patches/postgres_fix.py. Same fix for patched_commit.

Saved as patches/fix_frappe_pg_signatures.sh so we can re-apply after
every `bench update` or fresh install. The comment block in the
script documents why it exists and links the upstream issue (TODO:
file PR at excel-azmin/frappe_pg). docs/SETUP.md §7 was updated to
mention the post-install patch step, the nginx-IP-cache gotcha that
will produce a confusing 502 if you only restart the backend, and
the correct repo (excel-azmin, not the-commit-company that I had
hallucinated in the previous commit).

Smoke test results post-install:
  ping, Customer list, Sales Invoice list, Service Location list,
  customer_balance Server Script, ops SPA, hub /health — all 200.
2026-05-21 15:15:31 -04:00
louispaulb
c31a9e029e docs: recommend frappe_pg community app for ERPNext PostgreSQL compat
ERPNext was built for MariaDB; we run it on PostgreSQL because that's
what fit the legacy migration. Frappe's SQL generator is loose on
MariaDB (missing GROUP BY columns OK, double-quoted strings OK,
HAVING without GROUP BY OK) but strict on Postgres, so we end up
hand-patching files in `patches/fix_pg_groupby.py` after every
ERPNext upgrade. The community has packaged a comprehensive fix as
a Frappe app — `frappe_pg` — that covers the same bugs in one
place. The cleaner path long-term is to install that app instead
of growing our own patch set.

Two doc updates:

  - docs/architecture/overview.md §6 item 8 — full background:
    the 3 SQL patterns that break (GROUP BY, HAVING, double-quoted
    string literals), the 12 hotspots we've already patched, the
    4 known remaining (bank_clearance, bank_reconciliation_tool,
    accounts/utils L1660, gross_profit), and the install
    recommendation with trade-offs (pin a commit, validate on
    staging, keep our patches as backup for 4-6 weeks).

  - docs/SETUP.md §7 — quick-start install commands for whoever
    decides to flip the switch, plus the warning about pinning
    rather than tracking main. Also notes that custom Server
    Scripts with raw SQL (like `customer_balance`) need the same
    single-quote vs double-quote vigilance even after installing
    frappe_pg, and the export-fixtures hint to version-control
    them.
2026-05-21 14:40:36 -04:00
louispaulb
10afd696ae fix(migration): clean address_line + postal_code + connection_type at import
Three legacy data-quality issues that were leaking into ERPNext on every
import run. Caught while auditing C-LPB4's mis-pinned dispatch job.

1. **Postal code embedded in address_line.** Legacy `gestionclient` had
   rows like `2200-3 chemin de la riviere de la guerre  J0S1B0` with
   the postal code concatenated at the end (and the same code repeated
   in the dedicated zip column). Caused 48-char address_line on what
   should have been a 39-char address. Now stripped at import: a regex
   matches `\\s+<FSA><LDU>\\s*$` (with or without space) and removes
   it; the dedicated postal_code field carries the canonical form.

2. **Abbreviations + Cobol-style capitalization.** Legacy stored
   `2066 Ch De La 1Re-Concession` instead of the canonical
   `2066 Chemin de la 1re-Concession`. ABBREV_MAP expands `Ch` →
   `Chemin`, `Av` → `Avenue`, `Bd`/`Boul` → `Boulevard`, `Rte` →
   `Route`, `St-` → `Saint-`, `Ste-` → `Sainte-`, `Mtl` → `Montréal`.
   Title-casing rule preserves French articles lowercase (`de`, `du`,
   `des`, `la`, `le`, `les`, `au`, `aux`, `à`, `et`, `sur`, `en`)
   and ordinal markers (`1re`, `2e`, `3e`). 96 SLs in production had
   the `1Re-Concession` style; they'll be re-normalized on next
   migration run.

3. **`connection_type` left empty even when ONT/CPE devices existed.**
   Pre-loads device→delivery mapping at import start; if the legacy
   delivery has any device whose category/name/model contains "ont",
   "onu", "cpe", "fibre", "gpon", or "ftth", we set
   connection_type='Fibre FTTH'. Without devices on file, the field
   stays empty (rep fills it later) — we don't guess.

4. **`postal_code` normalized too** — `j0s1b0` → `J0S 1B0` (uppercase
   + canonical space). Was being inserted in lowercase no-space form.

Self-tested on 8 representative cases including the actual broken
records found in production (LOC-15903, LOC-6227, LOC-4 / C-LPB4).

These changes affect only re-imports of locations. Existing data
needs a separate backfill script — a follow-up will cover that
either as a one-shot migration or by running the existing
`reimport_subscriptions.py` after this script.
2026-05-08 15:38:19 -04:00
louispaulb
1186e50bbe fix(ops/client): cancelled subs no longer inflate monthly total + Lieu link in-app
Three connected dispatcher-facing issues from C-LPB4 audit:

1. **Monthly total was wrong on customer cards.** Section subtotal and
   `locSubsMonthlyTotal` summed `actual_price` for ALL subscriptions
   regardless of status, so cancelled rows (rendered with strikethrough)
   still pumped up the displayed billing figure. C-LPB4 showed
   "Total mensuel: 86,10$" computed as `196.05 - 109.95 = 86.10`,
   where 196.05 included 3 cancelled internet plans (Megafibre 80,
   TEST-E2E-FTTH, FTTH100 — all struck through in the UI). Real
   active monthly is 5.00$ (109.95 active + 5 frais réseau − 109.95
   loyalty rebate). Fixed both `sectionTotal` and `locSubsMonthlyTotal`
   /`locSubsAnnualTotal` to filter on `status === 'Active'`.

2. **"Lieu" link from a dispatch task pointed to ERPNext desk** which
   shows a raw doctype form (no abonnements, no totals, no contacts —
   just the bare fields). Now points in-app to
   `#/clients/<customer>?location=<SL>`. ClientDetailPage reads the
   query string on mount and:
     • scrolls the matching `loc-card` into view
     • pulses an indigo halo around it for ~2s so the rep finds it
       immediately even when the customer has many service locations.

3. **The shipping/billing distinction was invisible** on the customer
   page. Added an "Adresses de livraison" badge next to the "Lieux de
   service" section title — clarifies that this section IS the
   shipping address, distinct from the (future) billing address that
   will live on the Customer record. Cosmetic for now; the data
   migration to formalize that distinction is the next step.

These three round out the C-LPB4 audit triggered by the wrong
mapbox-pin location: now the customer card on the dispatcher's
screen shows correct totals, the dispatch link drops them right at
the spot they're trying to reach, and the role of each address-bearing
record is named explicitly.
2026-05-08 11:21:18 -04:00
louispaulb
ab7644e6de fix(ops/dispatch): /desk/<DocType>/ broken URL → /app/<slug>/ + add /address/validate hub
Two things ride together because the user noticed the URL bug while
testing the work-in-progress address validation:

1. **Broken Frappe URL pattern.** Three places in the dispatch UI
   were generating `/desk/Service Location/<id>` and
   `/desk/Dispatch Technician/<id>` links — both return "Page not
   found" on Frappe v14+ (= our v16) because the modern desk URL
   format is `/app/<slug>/<id>` where slug is lowercase + hyphens.
   Fixed in:
     • RightPanel.vue (Lieu link in the job details panel)
     • DispatchPage.vue (Lieu in the job ctx menu)
     • DispatchPage.vue (Ouvrir dans ERPNext in the tech ctx menu)

2. **`POST /address/validate` endpoint** on the hub. Wraps the
   existing RQA Supabase search (`address-search.js`) with a
   confidence-scored output:
     • exact_match (boolean) — score >= 0.7
     • best (the top RQA candidate with aq_address_id, lat, lng)
     • candidates[] (top 5 ranked)
     • confidence (0..1)
     • recommendation: validated | review | unmatched
   Score combines civic-number exact match, road-name fuzzy overlap,
   FSA+full postal-code bonuses, and city-name bonus. The endpoint
   is called from ops UI when adding/editing a Service Location to
   auto-populate aq_address_id + canonical lat/lng instead of
   trusting human typing or Mapbox geocode.

(Custom Fields aq_address_id, address_validation_status,
address_validated_at, linked_address have been added on Service
Location via the Frappe REST API in a separate operation — not in
this commit since they're DB-only.)
2026-05-08 11:01:32 -04:00
louispaulb
f4ae023302 fix(ops/dispatch): surface customer + service-location links from a job + fix bad coords
Two related issues, one PR:

1. **Bad coords** on customer C-LPB4's "Wifi buggy" job (DJ-MNP8WIKT).
   Address on file is `691 rue des Hirondelles, Saint-Michel J0L2J0`,
   but the saved lat/lng (-73.677086, 45.159206) reverse-geocodes to
   `2336 rue René-Vinet, Sainte-Clotilde J0L1W0` — ~9 km away. The
   delta matches the Gigafibre HQ default fallback (-73.6756, 45.1599)
   pretty closely, suggesting the geocoder either failed silently at
   Service Location creation time or got pinned to the HQ centroid.

   Fixed live in DB (UPDATE on tabService Location LOC-0000000004 +
   tabDispatch Job DJ-MNP8WIKT to lng=-73.5792377, lat=45.2408452,
   verified via Nominatim against the typed address). The job pin
   should now show on the correct house.

2. **No way to jump from a job to the client** — the dispatcher had
   to memorize/type the customer ID. Now both the RightPanel and the
   job context-menu surface clickable shortcuts:
     • Client → `#/clients/<id>` (opens ClientDetailPage in-app)
     • Lieu  → `/desk/Service Location/<id>` (opens ERPNext in a new
       tab; the ops SPA doesn't have a dedicated SL detail page)

   Required wiring `customer` + `serviceLocation` into the job map in
   `stores/dispatch.js` — the API (`fetchJobsFast` uses `["*"]`) was
   already returning the fields, the store just wasn't surfacing them.

Note on the deeper bug: the SL lat/lng is the source of truth and the
job currently *copies* it at creation time (rather than reading from
the SL link dynamically). If a Service Location's coords are corrected
after a job exists, the job retains stale coords. A follow-up could
either (a) re-read on render, or (b) trigger a backfill when SL coords
change. Out of scope for this fix — for now, the dispatcher who fixes
an SL must also update any open jobs at that location.
2026-05-08 10:29:59 -04:00
louispaulb
2ec5e49a06 docs: fix DocuSeal hostname (sign.gigafibre.ca, not docs.gigafibre.ca)
Three places in last week's docs refresh got the DocuSeal URL wrong —
must have been a copy-paste glitch since other parts of the same docs
(roadmap.md, module-interactions.md, archive snapshots) had it right.
Verified against the deployed Traefik labels:

  traefik.http.routers.docuseal.rule = Host(`sign.gigafibre.ca`)

`docs.gigafibre.ca` doesn't even resolve in DNS. Fixed in:
  • README.md (services table)
  • docs/README.md (services tree)
  • docs/architecture/overview.md (infra ASCII diagram)
2026-05-08 09:25:29 -04:00
louispaulb
0f8d2b0565 docs: bring all docs in sync with the May 2026 reality
Mass refresh — the docs were last touched 2026-04-22, two weeks behind
shipped reality. This commit updates 9 files to reflect current truth.

WHAT CHANGED IN THE PRODUCT (since 22 Apr) THAT THE DOCS NOW REFLECT:

  • Oktopus CE / TR-369 stack decommissioned (containers + volumes +
    images all removed; broker had filled /dev/sdb with 75 GB of debug
    logs and took ERPNext down for 4 days). Hub gates the integration
    behind OKTOPUS_DISABLED=1 — modules retained, no-op'd at runtime.
  • dispatch.gigafibre.ca (legacy PHP SPA) replaced by an nginx 301
    redirect to /ops/#/dispatch.
  • Top toolbar of the dispatch module: collapsed to single-color
    Lucide icons + ⋯ overflow menu + "Vue principale ▾" + "[👥 N ▾]"
    resource type chip (defaults to techs, materials in the dropdown
    only when relevant).
  • Tech home base / departure point: editable per-tech via 📍 button,
    address geocode (Nominatim) or click-on-map picker, right-click
    on tech pin opens the same actions. Map defaults centered on
    Gigafibre HQ (1867 chemin de la Rivière, Sainte-Clotilde) instead
    of downtown Montreal.
  • POST /auth/users invite flow on the hub: creates the Authentik
    user, sets a temp password, mails it via Mailjet (Authentik's
    own recovery flow isn't configured), creates the matching ERPNext
    System User. Surfaced in ops Settings → Utilisateurs → Inviter.
  • Two Authentik instances clarified as parallel-and-permanent (not
    a migration): auth.targo.ca for staff, id.gigafibre.ca for clients.

FILES TOUCHED:

  README.md — service table refreshed, arch diagram redrawn (no
    Oktopus row), auth section explains the invite flow + two
    parallel instances.
  docs/architecture/overview.md — new "Decommissioned" section,
    correct retirement status for dispatch-app + apps/field, two
    Authentik instances explicitly distinguished, dev-gotchas list
    rewritten (drops MongoDB AVX, adds log-rotation hard-learned
    lesson, adds note about Authentik recovery flow).
  docs/architecture/data-model.md — Step 5 hardware provisioning
    now describes the GenieACS path (TR-069 Inform → preset push)
    instead of the dead TR-369 path.
  docs/architecture/module-interactions.md — oktopus.js and
    oktopus-mqtt.js entries marked as gated, provision.js note
    updated, GenieACS row in external-integrations updated, MQTT
    row removed from real-time channels, interaction matrix loses
    the Oktopus column and gains an Authentik admin REST cell.
  docs/features/dispatch.md — Top bar section completely rewritten
    to match the current chrome (left/center/right regions,
    single-color Lucide, dropdowns); new Tech home base section
    documenting the 📍 + map-pick + right-click flows; retirement
    note now reads as a status, not a plan.
  docs/features/cpe-management.md — full rewrite. Oktopus migration
    plan replaced by a "decommissioned" note + the existing GenieACS
    + modem-bridge architecture as the steady state. TP-Link XX230v
    deep-dive sections preserved (still accurate).
  docs/README.md, docs/features/README.md, docs/roadmap.md —
    intent-table descriptions and live-URLs table corrected.

The docs/archive/ snapshots (2026-04-18, 2026-04-19) are untouched —
they're historical and should remain that way.
2026-05-05 20:10:40 -04:00
louispaulb
cbeb61e04e feat(hub+ops): user invite flow sends temp password via Mailjet + dev .env.example
A few connected fixes around the invite UI shipped in 81d61aa:

1. **Bug in 81d61aa**: `auth.js` referenced `erpFetch` without importing
   it, so every invite returned `erpnext.ok=false` with the silent
   "erpFetch is not defined" error in the catch. Imported it from
   ./helpers alongside the other helpers we already used.

2. **Authentik recovery flow not configured** (caught while smoke-testing):
   the brand `auth.targo.ca` has `flow_recovery=None` and no SMTP, so
   `POST /core/users/{pk}/recovery_email/` returned 400 "No recovery
   flow set." Rather than build out a full Authentik recovery flow
   via API (multiple stages, brand patch, SMTP env var changes), the
   hub now generates a strong-but-readable temp password
   (`X7K2-9NQB-4GHM-3RTW` style — no look-alike chars), POSTs it via
   `/core/users/{pk}/set_password/`, and emails it via the existing
   Mailjet SMTP (already wired into lib/email.js for invoice sends).
   Returns `{temp_password, password_set, email_sent}` so the admin
   has a fallback if Mailjet drops the message.

3. **Settings dialog** now shows a credentials panel after submit:
     • Green banner "✓ Courriel envoyé" when email_sent=true
     • Yellow "⚠ transmettez manuellement" when email_sent=false
     • The temp password as a copyable field either way
     • ERPNext User creation status

4. **Dev onboarding**: added `apps/ops/.env.example`,
   `services/targo-hub/.env.example`, and a top-level `docs/SETUP.md`
   that explains the local-dev flow (clone → cp .env.example .env →
   npm install → npx quasar dev). The example envs are commented
   per-section so a new dev knows which keys correspond to which
   external integration. None of the real secrets are checked in —
   the .gitignore already covers .env files.
2026-05-05 19:50:06 -04:00
louispaulb
81d61aa9d9 feat(ops/auth): invite-user UI in Settings — creates Authentik + ERPNext + recovery email
Surfaces a "Inviter" button in Settings → Utilisateurs that, in one
round-trip:

  1. Creates the Authentik user (random password, requested OPS_GROUPS,
     auto username from local-part of email with collision suffix).
  2. Triggers Authentik's recovery email so the user picks their own
     password on first login. If the Email stage isn't configured,
     falls back to /core/users/{pk}/recovery/ which returns a one-time
     URL the admin can copy + send via SMS or Slack.
  3. Creates the matching ERPNext System User with the requested
     roles (default: Employee) and `social_logins=[{provider:authentik,
     userid:email}]` so OAuth2 finds them on first SSO login.
     send_welcome_email=1 also fires Frappe's invite mail.

Idempotent on both sides: if the Authentik user already exists, we
PATCH the requested groups; if the ERPNext User exists, we skip the
POST and return existing=true. Lets the admin re-invite somebody
after a botched first try without breaking anything.

UI:
  • "Inviter" button next to the user search bar, gated by the
    `manage_users` capability (existing pattern).
  • q-dialog with full_name + email + chip-pickable Authentik groups
    (admin/sysadmin/tech/support/comptabilite/facturation/dev) + a
    comma-separated ERPNext roles input (defaults to Employee).
  • Optimistic insert into the visible list on success; the next
    search reconciles.
2026-05-05 15:29:18 -04:00
louispaulb
66b358d568 refactor(ops/dispatch): single-color Lucide icons + tech-first resource filter
Two issues conflated in the same PR because they touch the same pixels:

1. **Resource filter no longer treats techs and materials as equals.**
   Was a 3-button inline toggle [Tous][👤 45][🔧 6] with all three
   visually similar — and the wrench glyph clashed with the wrench
   used for the filter-settings button. Now:
     • Default = 'human' (techs only). Materials are secondary
       resources; they don't deserve front-of-bar real estate.
     • Single chip [👥 45 ▾] in the toolbar. Click → dropdown:
         · Techs (45)         ← active by default
         · Matériel (6)       (only shown if materialCount > 0)
         · Tous (51)          (only shown if materialCount > 0)
     • Defaults to localStorage 'sbv2-filterResType' if previously
       persisted, otherwise 'human' instead of '' (was '').

2. **Mixed-style icons (emoji + Lucide SVG) replaced with consistent
   single-color Lucide-style strokes.**
   Each is a stroke-only inline <svg> with stroke="currentColor", so
   they inherit the surrounding text color (no green/red/yellow
   tinting). Added to the existing ICON set in useHelpers.js:
     user, users, package, sliders, chevDown, map, clipboard,
     sparkles, signal, rotateCw, alertTri, moreH, pause, play,
     externalLink, target, calendar
   Replaced in the dispatch top toolbar:
     ⚠️  → ICON.alertTri          (overload alert)
     📋  → ICON.clipboard         (unscheduled jobs)
     🗺  → ICON.map               (map toggle)
     🗓  → ICON.calendar          (planning toggle)
     👥  → ICON.users             (team-jobs button + Ressources menu)
     🔧  → ICON.sliders           (filter-settings — was wrench, which
                                   collided with the materials filter)
     👤/🔧 → ICON.users / .package (resource type dropdown)
     ↻  → ICON.rotateCw           (refresh in ⋯ menu)
       → ICON.sparkles           (AI in ⋯ menu)
     📡  → ICON.signal            (offers in ⋯ menu)
     ↗  → ICON.externalLink       (ERPNext link in ⋯ menu)
     ⋯  → ICON.moreH              (the ⋯ button itself)
   .sb-icon-svg gives them consistent sizing (14px in buttons, 15px
   in dropdown items, 16px in the ⋯ trigger) so they're crisp at all
   the spots they appear.

Emojis still in place elsewhere (job-tile chips, status badges, etc.)
will be migrated incrementally — out of scope for this pass which
only targeted the user's visible header.
2026-05-05 14:31:00 -04:00
louispaulb
16343b61e1 fix(ops/dispatch): top bar polish — visible ⋯ menu, collapsed AI, fly-to tech, views dropdown
Four fixes around the dispatch header following dispatcher feedback:

1. **⋯ overflow menu was invisible**: .sb-header had `overflow:hidden`,
   which clipped the absolutely-positioned dropdown right at the
   header's bottom edge. Switched the header to `overflow:visible`
   (children all have flex-shrink:0 + a flex:1 center, so the layout
   doesn't actually overflow horizontally). Bumped z-index to 5000
   for safety on top of map/calendar layers.

2. **NLP/Assistant IA bar hidden by default**: was eagerly rendering
   on every page load, with the long French placeholder polluting
   the header below the toolbar. The user just wanted the icon. Now
   `nlpVisible` defaults to false, persisted in localStorage so power
   users who flip it on keep it open across sessions. Toggle still
   lives in the ⋯ menu.

3. **Click a tech in the resource list now flies the map to them**:
   selectTechOnBoard previously only opened the map panel. Now it
   also `map.flyTo({ center })` using `tech.gpsCoords ?? tech.coords`
   — live Traccar position wins when the tech is online; falls back
   to the saved home base. Animated, deferred a tick so map.resize()
   happens first, otherwise flyTo can land on garbage coords during
   the panel's open transition.

4. **Board view tabs collapsed into a "Vue principale ▾" dropdown**:
   was [Vue principale][Par région][+] inline. Now a single button
   showing the active view; click reveals the others + the future
   "+ Nouvelle vue" entry. Same dropdown component as the ⋯ menu
   (shared CSS, click-outside + ESC close).
2026-05-05 14:17:33 -04:00
louispaulb
96a84c3e48 refactor(ops/dispatch): consolidate top toolbar with overflow ⋯ menu
The header right-side was getting noisy — 8 buttons + 2 indicators
all competing for screen width, with two visually-similar 📡 icons
(offer pool vs GPS settings) that confused dispatchers. On narrower
laptops the bar wrapped or icons overflowed.

New layout:

  [⚠ overload] [📋 unassigned + count] [🗺 Carte] [Publier + count] [+ WO] [⋯]

Everything else dropped into the ⋯ dropdown:
  • ↻ Actualiser
  •  Assistant IA
  • 📡 Offres aux techs (with green count badge)
  • 👥 Ressources & GPS  ← was 📡 in the bar; this is also where
                          the tech-management UI (rename, deactivate,
                          home location, Traccar device link) lives
  • ↗ Ouvrir ERPNext (with the inline status dot)

The ⋯ menu closes on Escape, on click outside, and after picking an
item. Same close-handler chain that already serves the job/tech
context menus.

The kept-up-front buttons all have either a status badge (counts,
overload alert) or are the primary CTAs (Publier, + WO) — so the
dispatcher's eye stays on workflow signal, not on chrome.
2026-05-05 14:07:04 -04:00
louispaulb
c96092e9e8 feat(ops/dispatch): right-click tech pin + click-on-map home picker + center map on HQ
Three connected UX changes:

1. **Map centered on Gigafibre HQ on first load** —
   Sainte-Clotilde (lng=-73.6756, lat=45.1599), zoom 10 — covers the
   service area (Sainte-Clotilde + Châteauguay + Napierville +
   Hemmingford). Was downtown Montréal.

2. **Right-click on a tech pin** opens the existing techCtx menu
   (already used from the calendar via @ctx-tech). New entries:
     • 📍 Adresse de départ…  → openTechHomeDialog
     • 🎯 Choisir sur la carte → startTechGeoFix (mirrors the existing
                                  geoFixJob flow used for jobs)

3. **The 📍 button in the GPS sidebar** now offers a 2-option chooser
   first: "Saisir une adresse" or "Cliquer sur la carte". Picking the
   map option drops the user into geoFixTech mode.

Implementation:

  • useMap.js: new geoFixTech ref + startTechGeoFix/cancelTechGeoFix
    + a contextmenu listener on each tech outer wrapper that calls
    openTechCtx(e, tech). The map's main click handler now branches:
    if geoFixTech is set, persist the lng/lat via saveTechHome (passed
    in via deps as a forward-bound arrow because saveTechHome is
    destructured below the useMap call in DispatchPage).
  • DispatchPage.vue: new banner shown while in pick mode (animated
    indigo bar at top, "Cliquez sur la carte pour {tech}", with a
    cancel button); ESC also cancels.
  • dispatch-styles.scss: .sb-geofix-banner styles + reusing the
    existing pulse keyframe.
2026-05-05 14:02:26 -04:00
louispaulb
060cc034a8 feat(ops/dispatch): editable tech home base + new default at Gigafibre HQ
Two changes around tech "departure point" coords (used for route
optimization when the tech has no live GPS yet):

1. New default fallback = 1867 chemin de la Rivière, Sainte-Clotilde
   (Gigafibre HQ, lng=-73.6756, lat=45.1599). Was downtown Montréal,
   which never made sense — every tech started the day with a 70 km
   imaginary commute.

2. Per-tech editable home base via a 📍 button on each row of the
   tech sidebar. Clicking it opens a dialog that accepts either:
     • a free-text address — geocoded via OpenStreetMap Nominatim
       (browser-side, sane User-Agent, no hub proxy needed)
     • or a literal "lat, lng" pair pasted directly
   On confirm: PUT to ERPNext (Dispatch Technician.latitude /
   .longitude), patch the local store row, and trigger a route
   recompute since the start point changed.

   The geocode hits Nominatim public — fine for a low-volume
   internal tool. If we ever exceed their fair-use limits, swap to
   the existing /address-search hub route which already has the
   AQ + RQA pipeline.
2026-05-05 13:53:14 -04:00
louispaulb
490b9ce457 fix(ops/dispatch): tech pin drifts away from lat/lng on map zoom
The map marker container was being created with an inline
`position:relative`, which overrode Mapbox GL's `.mapboxgl-marker`
class (which applies `position:absolute`). Mapbox writes
`transform: translate(<x>, <y>)` to that exact element on every
zoom/pan frame to project lat/lng → screen coordinates. With the
element kept in the document flow (relative), the transform is
interpreted against the document origin instead of the map pane,
so the pin visually drifts as the user zooms in on a tech.

Removing the inline `position:relative` lets the Mapbox class win.
The SVG ring and the avatar div are children with `position:absolute`
inside outer; absolute children only need a positioned ancestor to
form a containing block — `position:absolute` (Mapbox's value)
qualifies just as well as relative, so the avatar stays centered.
2026-05-05 13:40:29 -04:00
louispaulb
a5cfe997b6 chore(hub): gate Oktopus integration behind OKTOPUS_DISABLED flag
The Oktopus TR-069 stack is being decommissioned (broker + ACS + Mongo
+ NATS + adapters). Its MQTT broker was running with debug logging and
spammed 75 GB of "failed publishing packet" lines into a single Docker
log over 13 days — that's what just took ERPNext down for 4 days when
/dev/sdb hit 100 %.

Surface here: hub no longer pulls in the oktopus / oktopus-mqtt modules
when OKTOPUS_DISABLED is set (default = disabled). Keeps the modules
in the tree so we can re-enable later by flipping the env var to 0,
but stops them attempting reconnects to a stack that no longer exists.

  • server.js: late-load oktopus + oktopus-mqtt only when enabled.
    Routes /oktopus/* now return 410 Gone with a clear message.
  • provision.js: same gate. The on-scan handler already had a soft
    `if (oktopus && ...)` guard so it naturally no-ops when the
    module isn't loaded — no logic change needed there.

Server-side env (set in /opt/targo-hub/.env on prod):
  OKTOPUS_DISABLED=1
2026-05-04 10:34:36 -04:00
louispaulb
218f6fc7b1 feat(ops): Service Contract detail view + sub-delete redirects there
Contract termination is a fee-bearing, auditable workflow — it belongs
on the contract, not buried in a sub's delete dialog. Standard SaaS /
telecom practice: subs are an immutable event stream, contracts
orchestrate their lifecycle.

ServiceContractDetail.vue (new)
  • Status banner: contract type, dates, status — "Résilier" button
    when actionable, termination invoice link when already résilié.
  • Term progress bar: months_elapsed / duration_months with color
    ramp (primary → amber near end → positive when done).
  • Financial summary grid: mensualité, abonnement (clickable), devis,
    lieu, total avantages, résiduel, signature method & date.
  • Benefits detail table: per-row description, regular_price vs
    granted_price, économie, reconnu à date, et "À rembourser"
    (valeur résiduelle) — this is what the rep needs to see before
    deciding to break a contract.
  • Termination recap (only when status=Résilié): date, raison,
    penalty breakdown, link to the termination invoice.
  • "Résilier" action runs a 2-step dialog: first calls
    /contract/calculate-termination for the preview, then prompts for
    a reason (textarea, min 3 chars) before firing /contract/terminate.
    On success: cascade-cancels the linked sub (status=Annulé +
    end_date + cancellation_date — no hard delete), mutates the
    local doc so the modal refreshes in place, and emits
    contract-terminated so the parent page updates its sub + contract
    rows + drops an audit comment on the customer.

DetailModal
  • SECTION_MAP now routes Service Contract → ServiceContractDetail.
    Also added 'Service Subscription' → SubscriptionDetail (same
    template fits; was falling through to the generic grid).
  • Re-emits contract-terminated so the parent can listen.

ClientDetailPage
  • confirmDeleteSub: when a live contract references the sub, the
    dialog now simply redirects the rep to the contract modal
    ("Voir le contrat") instead of trying to do termination from
    the sub row. Terminal-state contracts (Résilié/Complété/Expiré)
    still get the inline link-scrub path so stale refs don't block
    a legit delete.
  • onContractTerminated: reflects the cascade locally — contract
    row → Résilié, sub row → Cancelled + end_date, audit Comment
    posted to the customer's notes feed.
2026-04-23 14:46:34 -04:00
louispaulb
64d5751149 feat(ops/client): contract-aware sub delete with termination preview
The raw DELETE on Service Subscription was blowing up with
LinkExistsError because Service Contract.service_subscription still
referenced the sub. Worse: silently unlinking a live contract would
cost the business the break fee (résidentiel = avantages résiduels,
commercial = mensualités restantes).

Now when the user clicks 🗑 on a sub:

  1. loadServiceContracts pulls `service_subscription` so the client
     can spot the link without a round-trip.
  2. If a non-terminal contract is linked, the dialog upgrades to:
       • header: Contract name + type
       • term bar: start → end, months elapsed / months remaining
         (pulled live from /contract/calculate-termination)
       • penalty breakdown box: total fee, split into benefits to
         refund + remaining months, plus a warning that a termination
         invoice will be created
       • radio: "Désactiver seulement (conserver le contrat)" vs
         "Résilier + facturer X$ + supprimer"
     Suspend-only route goes through toggleSubStatus (no fee).
     Terminate route hits /contract/terminate (status→Résilié +
     invoice), then unlinks + deletes the sub, and drops an audit
     line referencing the generated invoice.
  3. If the linked contract is already Résilié/Complété we just scrub
     the stale link inline in the plain confirm path so the
     dispatcher isn't forced into the termination UI.
2026-04-23 13:47:53 -04:00
louispaulb
349f9af2da feat(ops/client): edit/delete/reorder subscriptions + rebate nesting
- InlineField on monthly row price (dblclick) + annual row monthly base
  price. Saves via Service Subscription.monthly_price → mirrored back
  into the UI row's actual_price; drops an audit line on the customer
  timeline.
- Delete button (confirm dialog, v-if=can('delete_records')) on both
  monthly + annual rows. Uses deleteDoc + local splice + invalidates
  location + section caches.
- display_order custom Int field on Service Subscription, persisted in
  10-step increments on drag reorder (so manual inserts have room to
  squeeze between without a full re-number pass). loadSubscriptions
  sorts by display_order first so the dispatcher-controlled order
  survives a page reload and can drive invoice print ordering later.
- Rebate rows nested visually: 32px indent + arrow glyph + lighter
  red background + smaller type + inherited red color on the inline
  price input. Matches the invoice PDF grouping dispatchers expect.
2026-04-23 11:21:41 -04:00
louispaulb
dfd41ee993 fix(ops/client): consolidate on Service Subscription + catalog browse
Adding a forfait from the client detail dialog failed with `Update
failed: 417` because the code path manipulated ERPNext's stock
Subscription doctype — a parent/child (Subscription Plan rows) model
with tight validation ("Subscription End Date is mandatory to follow
calendar months"), and whose `plan` field expects an `SP-<hash>` doc
name rather than a free-form string.

Meanwhile all new subscription work — contract signing, chain
activation, prorated invoicing — already writes to our flat custom
`Service Subscription` doctype. The two systems were not talking to
each other: the Service Subscription created for CTR-00008 was
invisible in the client UI (which only read stock Subscription), and
the stock Subscription created by "Ajouter un service" was invisible
to the contract/chain system.

This commit makes Service Subscription the canonical doctype for
everything the ops UI does:

- useClientData.loadSubscriptions: read Service Subscription directly
  (flat doc → UI row) instead of reading stock Subscription + joining
  its Subscription Plan child rows to Items. Legacy stock Subscription
  rows (~39k from the 2026-03-29 migration) stay as audit records
  but are no longer surfaced.
- ClientDetailPage.createService: POST a Service Subscription doc
  (category inferred from item_group). No parent/child logic, no
  calendar-month coupling, no SP-<hash> plan reference. Manual
  description + price entry now works without a catalog pick.
- useSubscriptionActions.updateSub: drop the bogus `ASUB-*` name-based
  doctype detection (ASUB is not a real prefix — both stock and
  Service subs are named SUB-<hex|digits>) and always target Service
  Subscription. Also surface ERPNext's exception one-liner instead of
  raw HTML when an update fails.
- searchPlans: empty/short query now returns top-50 of the Subscription
  Plan catalog so dispatchers can browse instead of being forced to
  guess a name prefix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 11:07:54 -04:00
louispaulb
60e300335b fix(ops/TaskNode): drop credentials:'include' on job-delete fetch
The hub responds with `Access-Control-Allow-Origin: *`, and the CORS
spec forbids the wildcard + credentials combination. Firefox rejects
the preflight before any response reaches JS, surfacing as
"NetworkError when attempting to fetch resource" when a user clicks
"Supprimer cette tâche" on an already-completed step (or any step).

Every other HUB_URL call in the ops SPA already omits credentials —
aligning TaskNode with the rest of the codebase is the simplest fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 10:57:57 -04:00
louispaulb
ba4b5bae82 fix(chain+subs): safe job-delete, plan_name from Quotation, bi-dir sub link
- contracts.js: _inferPlanName now reads the Quotation's first positive-rate
  item ("Internet Megafibre 80 Mbps") instead of generic fallback.
- contracts.js: subPayload writes service_contract back-ref so an active/
  pending sub blocks its parent contract's deletion (LinkExistsError).
- contracts.js: GET /contract/audit-orphans[?fix=1] scans for orphaned subs
  (dangling contract link or no link at all) and contracts without a sub;
  filters out 2026-03-29 legacy-migration batch via LEGACY_CUTOFF.
- dispatch.js: deleteJobSafely() rewires children's depends_on to the
  victim's parent, re-parents descendants if victim was chain root, then
  deletes. POST /dispatch/job-delete exposes it. Fixes LinkExistsError
  when users delete a middle step in the UI.
- TaskNode.vue: confirmDelete calls /dispatch/job-delete and surfaces a
  warning when dependents will be rewired.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 10:19:56 -04:00
louispaulb
2aee8f31df fix(contracts): create pending Service Subscription on signing + test templates
Root cause of CTR-00008: _createBuiltInInstallChain only created Issue +
Dispatch Jobs. It never created a pending Service Subscription, so when
the chain's terminal job Completed, activateSubscriptionForJob found
nothing matching customer+service_location+status='En attente' to flip.
Result: 4/4 tasks done, no sub activation, no prorated invoice.

Changes:
- contracts.js: after chain creation, create Service Subscription with
  status='En attente' (plan_name + service_category inferred from the
  contract). Back-link it on Service Contract.service_subscription (a
  new custom field — the stock 'subscription' field on Service Contract
  points at the built-in ERPNext Subscription doctype, not ours).
- project-templates.js: add test_single (1-step) and test_parallel
  (diamond: step0 → step1 ∥ step2) for faster lifecycle testing.
  Extract chooseTemplate(contract) with precedence:
    contract.install_template → contract_type mapping → fiber_install.
- contracts.js: chain builder now uses chooseTemplate instead of
  hardcoded fiber_install, logs the chosen template per contract.
- _inferServiceCategory/_inferPlanName helpers map contract metadata
  into the Service Subscription's required fields.

Companion changes on ERPNext (custom fields, no code):
  Service Contract.service_subscription  Link → Service Subscription
  Service Contract.install_template       Select (fiber_install,
    phone_service, move_service, repair_service, test_single,
    test_parallel)

Retroactive repair for CTR-00008 applied directly on prod:
  → SUB-0000100003 (Actif), SINV-2026-700014 (Draft, $9.32 prorata).

Smoke test of test_single path on prod (CTR-00010 synthetic, cleaned up):
  template=test_single ✓  sub created ✓  activated on completion ✓
  prorated invoice emitted ✓

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 10:03:49 -04:00
louispaulb
9fda9eb0b0 refactor(targo-hub): add types.js, migrate acceptance+payments, drop apps/field
- lib/types.js: single source of truth for Dispatch Job status + priority enums.
  Eliminates hard-coded 'In Progress'/'in_progress'/'Completed'/'done' checks
  scattered across tech-mobile, acceptance, dispatch. Includes CLIENT_TYPES_JS
  snippet for embedding in SSR <script> blocks (no require() needed).

- lib/tech-mobile.js: applies types.js predicates (isInProgress, isTerminal,
  isDone, isUrgent) both server-side and client-side via ${CLIENT_TYPES_JS}
  template injection. Single aliasing point for future status renames.

- lib/acceptance.js: migrated 7 erpFetch + 2 erpRequest sites to erp.js wrapper.
  Removed duplicate "Lien expiré" HTML (now ui.pageExpired()). Dispatch Job
  creation uses types.JOB_STATUS + types.JOB_PRIORITY.

- lib/payments.js: migrated 15 erpFetch + 9 erpRequest sites to erp.js wrapper.
  Live Stripe flows preserved exactly — frappe.client.submit calls kept as
  erp.raw passthroughs (fetch-full-doc-then-submit pattern intact). Includes
  refund → Return PE → Credit Note lifecycle, PPA cron, idempotency guard.

- apps/field/ deleted: transitional Quasar PWA fully retired in favor of
  SSR tech-mobile at /t/{jwt}. Saves 14k lines of JS, PWA icons, and
  infra config. Docs already marked it "retiring".

Smoke-tested on prod:
  /payments/balance/:customer (200, proper shape)
  /payments/methods/:customer (200, Stripe cards live-fetched)
  /dispatch/calendar/:tech.ics (200, VCALENDAR)
  /t/{jwt} (55KB render, no errors)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 23:18:25 -04:00
louispaulb
01bb99857f refactor(targo-hub): add erp.js wrapper + migrate 7 lib files to it
Replaces hand-rolled `erpFetch` + `encodeURIComponent(JSON.stringify(...))`
URL building with a structured wrapper: erp.get/list/listRaw/create/update/
remove/getMany/hydrateLabels/raw.

Key wins:
- erp.list auto-retries up to 5 times when v16 rejects a fetched/linked
  field with "Field not permitted in query" — the field is dropped and the
  call continues, so callers don't have to know which fields v16 allows.
- erp.hydrateLabels batches link-label resolution (customer_name,
  service_location_name, …) in one query per link field — no N+1, no
  v16 breakage.
- Consistent {ok, error, status} shape for mutations.

Migrated call sites:
- otp.js: Customer email lookup + verifyOTP customer detail fetch +
  Contact email fallback + Service Location listing
- referral.js: Referral Credit fetch / update / generate
- tech-absence-sms.js: lookupTechByPhone, set/clear absence
- conversation.js: Issue archive create
- magic-link.js: Tech lookup for /refresh
- ical.js: Tech lookup + jobs listing for iCal feed
- tech-mobile.js: 13 erpFetch sites → erp wrapper

Remaining erpFetch callers (dispatch.js, acceptance.js, payments.js,
contracts.js, checkout.js, …) deliberately left untouched this pass —
they each have 10+ sites and need individual smoke-tests.

Live-tested against production ERPNext: tech-mobile page renders 54K
bytes, no runtime errors in targo-hub logs post-restart.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 23:01:27 -04:00
louispaulb
169426a6d8 refactor(targo-hub): extract ui/ kit, migrate tech-mobile to it
Introduces services/targo-hub/lib/ui/ as the shared kit for every magic-link
page served from the hub (tech mobile, acceptance, payments):

  design.css    tokens (--brand, --success, etc) + reset + all primitives
  components.js server-side HTML builders (badge/section/card/panel/statRow/
                tabBar) + shared date helpers (fmtTime/dateLabelFr/montrealDate)
                + canonical STATUS_META
  client.js     client-side api wrapper ($, toast, api.get/post, router.on/go)
                baked into every page — no more hand-rolled fetch+hashchange
  scanner.js    Gemini field-scan overlay (window.scanner.open(field,label,cb,ctx))
  shell.js      ui.page({title, body, bootVars, cfg, script, includeScanner})
                inlines everything into one self-contained HTML doc
  index.js      barrel

Migrates tech-mobile.js to the kit:
  - drops inline esc/toast/fmtTime/dlbl/STATUS_META/badge helpers
  - api.post('/status', {...}) instead of fetch(H+'/t/'+T+'/status', {...})
  - router.on('#job/:name', handler) instead of hand-rolled route()
  - scanner.open(field, label, cb, ctx) instead of ~60 lines of field-scan logic

Behavior preserved — rendered HTML keeps tabs, detail view, notes editor,
photo upload, per-field Gemini scans, Montreal-TZ date labels, v16 link-label
resolution. Verified live at msg.gigafibre.ca with a real TECH-4 token.

Sets up acceptance.js and payments.js to drop from ~700 → ~300 lines each
in the next commits by consuming the same primitives.
2026-04-22 22:47:19 -04:00
louispaulb
1d23aa7814 feat(tech-mobile): SPA redesign with tabs, detail view, notes, photos, field-scan
Rewrote msg.gigafibre.ca (tech magic-link page) from a today-only flat list
into a proper 4-tab SPA:
- Aujourd'hui: In Progress / En retard / Aujourd'hui / Sans date / À venir
- Calendrier: placeholder (phase 4)
- Historique: searchable + filter chips (Tous/Terminés/Manqués/Annulés)
- Profil: tech info, support line, refresh

Job detail view (hash-routed, #job/DJ-xxx):
- Customer + tap-to-call/navigate block
- Editable notes (textarea → PUT /api/resource/Dispatch Job)
- Photo upload (base64 → File doctype, is_private, proxied back via /photo-serve)
- Equipment section (inherited from overlay)
- Sticky action bar (Démarrer / Terminer)

Equipment overlay extended with per-field Gemini Vision scanners. Each
input (SN, MAC, GPON SN, Wi-Fi SSID, Wi-Fi PWD, model) has a 📷 that opens
a capture modal; Gemini is prompted to find THAT field specifically and
returns value+confidence. Tech confirms or retries before the value fills in.

Root cause of the "tech can't see his job" bug: page filtered
scheduled_date=today, so jobs on any other day were invisible even though
the token was tech-scoped. Now fetches a ±60d window and groups client-side.

vision.js: new extractField(base64, field, ctx) helper + handleFieldScan
route (used by new /t/:token/field-scan endpoint).

Also fixes discovered along the way:
- Frappe v16 blocks fetched/linked fields (customer_name, service_location_name)
  and phantom fields (scheduled_time — real one is start_time). Query now
  uses only own fields; names resolved in two batch follow-up queries.
- "Today" is Montreal-local, not UTC. Prevents evening jobs being mislabeled
  as "hier" when UTC has already rolled to the next day.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:19:00 -04:00
louispaulb
3db1dbae06 fix(contract): always run built-in chain + send ack SMS + default scheduled_date
Three bugs combined to make CTR-00008 (and likely others) land silently:

1. Fallback was count-based, not outcome-based.
   _fireFlowTrigger returned >0 when a broken Flow Template (FT-00005)
   "matched" on_contract_signed but did nothing. We took that as success
   and skipped the built-in install chain. Now we ALWAYS run the built-in
   chain; the idempotency check inside (look up existing Issue linked to
   contract) lets a healthy Flow Template short-circuit us naturally.

2. scheduled_date was null on all chained jobs.
   createDeferredJobs passed '' when no step.scheduled_date was set, and
   the fiber_install template doesn't set one. Jobs with null dates are
   filtered out of most dispatch board views, giving the user-visible
   "Aucune job disponible pour dispatch" symptom even after the chain was
   built. Default to today (via ctx.scheduled_date) so jobs appear on the
   board; dispatcher reschedules per capacity.

3. No post-sign acknowledgment to the customer.
   Previously the Flow Template was expected to send the confirmation SMS;
   since the template was broken, the customer got nothing after signing.
   Add _sendPostSignAcknowledgment that sends a "Bon de commande reçu"
   SMS with contract ref + service details + next steps. Fires only when
   the chain is actually created (not on idempotent skip) so we never
   double-notify.

Also:
- Resolve phone/email from cell_phone + email_billing (legacy-migrated
  Customer records use those fields, not Frappe defaults mobile_no /
  email_id) — otherwise we'd keep skipping SMS with "no phone on file".
- _createBuiltInInstallChain now returns { created, issue, jobs,
  scheduled_date, reason } so callers can branch on outcome.
- Export sendPostSignAcknowledgment so one-shot backfill scripts can
  re-notify customers whose contracts were signed during the broken
  window.
- Set order_source='Contract' (existing Select options patched separately
  to include 'Contract' alongside Manual/Online/Quotation).

Backfilled CTR-00008: ISS-0000250003 + 4 chained Dispatch Jobs all with
scheduled_date=2026-04-23, ack SMS delivered to Louis-Paul's cell.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:01:51 -04:00
louispaulb
aa5921481b feat: contract → chain → subscription → prorated invoice lifecycle + tech group claim
- contracts.js: built-in install chain fallback when no Flow Template matches
  on_contract_signed — every accepted contract now creates a master Issue +
  chained Dispatch Jobs (fiber_install template) so we never lose a signed
  contract to a missing flow config.
- acceptance.js: export createDeferredJobs + propagate assigned_group into
  Dispatch Job payload (was only in notes, not queryable).
- dispatch.js: chain-walk helpers (unblockDependents, _isChainTerminal,
  setJobStatusWithChain) + terminal-node detection that activates pending
  Service Subscriptions (En attente → Actif, start_date=tomorrow) and emits
  a prorated Sales Invoice covering tomorrow → EOM. Courtesy-day billing
  convention: activation day is free, first period starts next day.
- dispatch.js: fix Sales Invoice 417 by resolving company default income
  account (Ventes - T) and passing company + income_account on each item.
- dispatch.js: GET /dispatch/group-jobs + POST /dispatch/claim-job for tech
  self-assignment from the group queue; enriches with customer_name /
  service_location via per-job fetches since those fetch_from fields aren't
  queryable in list API.
- TechTasksPage.vue: redesigned mobile-first UI with progress arc, status
  chips, and new "Tâches du groupe" section showing claimable unassigned
  jobs with a "Prendre" CTA. Live updates via SSE job-claimed / job-unblocked.
- NetworkPage.vue + poller-control.js: poller toggle semantics flipped —
  green when enabled, red/gray when paused; explicit status chips for clarity.

E2E verified end-to-end: CTR-00007 → 4 chained jobs → claim → In Progress →
Completed walks chain → SUB-0000100002 activated (start=2026-04-24) →
SINV-2026-700012 prorata $9.32 (= 39.95 × 7/30).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 20:40:54 -04:00
louispaulb
07365d3b71 fix(tech-diag): warm-up fetch + no-redirect host so ping matches reality
Techs reported cloudflare.com showing 300+ms on the diagnostic page
while OS-level ICMP ping returned 5ms. The gap is entirely protocol
overhead:

- fetch() ≠ ICMP. Every call pays DNS + TCP + TLS + HTTP on top of
  the real RTT, which is easily 150–300ms cold on mobile LTE when the
  radio has to wake the RRC connection.
- Bare cloudflare.com redirects 301 → www.cloudflare.com, forcing a
  second DNS + TCP + TLS handshake for every "ping" and doubling
  the measured latency.
- TechDiagnosticPage.vue was also labeling the full 10MB download
  time as "Latence", so the number on the speed-test card was never
  a latency measurement at all.

Fixes, applied to both surfaces (Ops /j/diagnostic + Field /diagnostic):

- Swap cloudflare.com → 1.1.1.1/cdn-cgi/trace. 88-byte response, no
  redirect, no keepalive games — canonical "internet is up" endpoint.
- Warm-up fetch before every measurement. First call absorbs DNS +
  TCP + TLS + LTE wake; second call reports steady-state RTT. This
  applies to checkHosts() (ops) and resolveHost() (field composable).
- Split runSpeed() into separate ping + throughput measurements. Ping
  hits speed.cloudflare.com/cdn-cgi/trace (88 bytes on a warm
  connection); throughput hits /__down on the same origin so the TLS
  session is reused.

Deployed to production; smoke-verified:
- ops bundle TechDiagnosticPage.b925e02c.js contains
  '1.1.1.1/cdn-cgi/trace'
- field bundle DiagnosticPage.38a45f65.js contains the same
- zero bare 'cloudflare.com' hostname in either hosts array

Files:
- apps/ops/src/modules/tech/pages/TechDiagnosticPage.vue
- apps/field/src/composables/useSpeedTest.js
- apps/field/src/pages/DiagnosticPage.vue

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 16:08:24 -04:00
louispaulb
30a867a326 fix(tech): restore Gemini-native scanner + port equipment UX into ops
The ops tech module at /ops/#/j/* had drifted from the field app in two ways:

1. Scanner — a prior "restoration" re-added html5-qrcode, but the
   design has always been native <input capture="environment"> → Gemini
   2.5 Flash via targo-hub /vision/barcodes (up to 3 codes) and
   /vision/equipment (structured labels, up to 5). Revert useScanner.js
   + ScanPage.vue + TechScanPage.vue to commit e50ea88 and drop
   html5-qrcode from both package.json + lockfiles. No JS barcode
   library, no camera stream, no polyfills.

2. Equipment UX — TechJobDetailPage.vue was a 186-line stub missing the
   Ajouter bottom-sheet (Scanner / Rechercher / Créer), the debounced
   SN-then-MAC search, the 5-field create dialog, Type + Priority
   selects on the info card, and the location-detail contact expansion.
   Port the full UX from apps/field/src/pages/JobDetailPage.vue (526
   lines) into the ops module (458 lines after consolidation).

Rebuilt and deployed both apps. Remote smoke test confirms 0 bundles
reference html5-qrcode and the new TechJobDetailPage.1075b3b8.js chunk
(16.7 KB vs ~5 KB stub) ships the equipment bottom-sheet strings.

Docs:

- docs/features/tech-mobile.md — new. Documents all three delivery
  surfaces (legacy SSR /t/{jwt}, transitional apps/field/, unified
  /ops/#/j/*), Gemini-native scanner pipeline, equipment UX, magic-link
  JWT, cutover plan. Replaces an earlier stub that incorrectly
  referenced html5-qrcode.
- docs/features/dispatch.md — new. Dispatch board, scheduling, tags,
  travel-time optimization, magic-link SMS, SSE updates.
- docs/features/customer-portal.md — new. Plan A passwordless magic-link
  at portal.gigafibre.ca, Stripe self-service, file inventory.
- docs/architecture/module-interactions.md — new. One-page call graph
  with sequence diagrams for the hot paths.
- docs/README.md — expanded module index (§2) now lists every deployed
  surface with URL + primary doc + primary code locations (was missing
  dispatch, tickets, équipe, rapports, telephony, network, agent-flows,
  OCR, every customer-portal page). New cross-module edge map in §4.
- docs/features/README.md + docs/architecture/README.md — cross-link
  all new docs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:56:38 -04:00
louispaulb
7ac9a582c6 fix(portal): deploy Vue SPA to portal.gigafibre.ca, retire client.gigafibre.ca
Topology clarification:
- portal.gigafibre.ca = standalone nginx container serving /opt/client-app/
  (the actual Vue SPA). This is the real customer portal.
- client.gigafibre.ca = ERPNext frontend (exposes Frappe's password login
  form — dead-end UX, legacy MD5 attack surface).

Changes:
- apps/client/deploy.sh: target /opt/client-app/ directly with DEPLOY_BASE=/
  (was uploading into ERPNext's /assets/client-app/, which nothing serves).
  Atomic stage-and-swap + docker restart so the nginx bind-mount picks up
  the new inode.
- apps/portal/traefik-client-portal.yml: replace per-path /login and /desk
  blocks on client.gigafibre.ca with a catch-all 307 to portal.gigafibre.ca.
  Old bookmarks, old invoice links, and in-flight SMS all end up on the
  Vue SPA instead of Frappe's password page.
- apps/ops/package-lock.json: sync to include html5-qrcode transitive deps
  so `npm ci` in deploy.sh works from a clean checkout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:02:31 -04:00
louispaulb
2b04e6bd86 feat(portal): passwordless magic-link login — retire ERPNext /login
Customers no longer authenticate with passwords. A POST to the hub's
/portal/request-link mints a 24h customer-scoped JWT and sends it via
email + SMS; the /#/login Vue page sits on top of this and a navigation
guard hydrates the Pinia store from the token on arrival.

Why now: legacy customer passwords are unsalted MD5 from the old PHP
system. Migrating hashes to PBKDF2 would still require a forced reset
for every customer, so it's simpler to drop passwords entirely. The
earlier Authentik forwardAuth attempt was already disabled on
client.gigafibre.ca; this removes the last vestige of ERPNext's
password form from the customer-facing path.

Hub changes:
  - services/targo-hub/lib/portal-auth.js (new) — POST /portal/request-link
    • 3-requests / 15-min per identifier rate limit (in-memory Map + timer)
    • Lookup by email (email_id + email_billing), customer id (legacy +
      direct name), or phone (cell + tel_home)
    • Anti-enumeration: always 200 OK with redacted contact hint
    • Email template with CTA button + raw URL fallback; SMS short form
  - services/targo-hub/server.js — mount the new /portal/* router

Client changes:
  - apps/client/src/pages/LoginPage.vue (new) — standalone full-page,
    single identifier input, success chips, rate-limit banner
  - apps/client/src/api/auth-portal.js (new) — thin fetch wrapper
  - apps/client/src/stores/customer.js — hydrateFromToken() sync decoder,
    stripTokenFromUrl (history.replaceState), init() silent Authentik
    fallback preserved for staff impersonation
  - apps/client/src/router/index.js — PUBLIC_ROUTES allowlist + guard
    that hydrates from URL token before redirecting
  - apps/client/src/api/auth.js — logout() clears store + bounces to
    /#/login (no more Authentik redirect); 401 in authFetch is warn-only
  - apps/client/src/composables/useMagicToken.js — thin read-through to
    the store (no more independent decoding)
  - PaymentSuccess/Cancel/CardAdded pages — goToLogin() uses router,
    not window.location to id.gigafibre.ca

Infra:
  - apps/portal/traefik-client-portal.yml — block /login and
    /update-password on client.gigafibre.ca, redirect to /#/login.
    Any stale bookmark or external link lands on the Vue page, not
    ERPNext's password form.

Docs:
  - docs/roadmap.md — Phase 4 checkbox flipped; MD5 migration item retired
  - docs/features/billing-payments.md — replace MD5 reset note with
    magic-link explainer

Online appointment booking (Plan B from the same discussion) is queued
for a follow-up session; this commit is Plan A only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 13:25:28 -04:00
louispaulb
90f5f2eaa0 fix(field/ops): restore live camera + multi-barcode scanning at /j/scan
The Apr 22 refactor (41d9b5f) collapsed the tech scanner to Gemini-only
photo capture, dropping the live camera viewport and client-side multi-
barcode detection. Techs lost the fast point-and-scan flow that handles
90% of routine installs.

Restored as a hybrid: html5-qrcode as the primary path (instant, offline,
standard QR/barcode), Gemini kept as a second-chance fallback for hard
labels (damaged stickers, text-only serials, unusual symbologies). Offline
queue + scanEquipmentLabel() preserved unchanged.

Three tabs, defaulting to live camera:
  - Caméra — continuous html5-qrcode stream, detection auto-beeps
  - Photo  — native camera; full-image + 3-strip local scan, Gemini fallback
  - Manuel — plain text input

Both apps/field and apps/ops updated in lockstep so nothing drifts while
apps/field is being folded into apps/ops/j.

Run `npm install` in apps/ops/ to pull in html5-qrcode before the next build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 13:22:36 -04:00
louispaulb
beb6ddc5e5 docs: reorganize into architecture/features/reference/archive folders
All docs moved with git mv so --follow preserves history. Flattens the
single-folder layout into goal-oriented folders and adds a README.md index
at every level.

- docs/README.md — new landing page with "I want to…" intent table
- docs/architecture/ — overview, data-model, app-design
- docs/features/ — billing-payments, cpe-management, vision-ocr, flow-editor
- docs/reference/ — erpnext-item-diff, legacy-wizard/
- docs/archive/ — HANDOFF-2026-04-18, MIGRATION, status-snapshots/
- docs/assets/ — pptx sources, build scripts (fixed hardcoded path)
- roadmap.md gains a "Modules in production" section with clickable
  URLs for every ops/tech/portal route and admin surface
- Phase 4 (Customer Portal) flipped to "Largely Shipped" based on
  audit of services/targo-hub/lib/payments.js (16 endpoints, webhook,
  PPA cron, Klarna BNPL all live)
- Archive files get an "ARCHIVED" banner so stale links inside them
  don't mislead readers

Code comments + nginx configs rewritten to use new doc paths. Root
README.md documentation table replaced with intent-oriented index.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:51:33 -04:00
louispaulb
30bfe6175e docs: add Phase 2.7 — field ↔ ops unification at /j
Records what shipped in e50ea88 (scan + device pages, offline store,
Gemini vision pipeline) and lays out the remaining phases: PWA
hardening, auth unification, magic-link tech access, flow-runtime
integration, and final apps/field removal.

Fixes stale `/t/{token}` route reference in Phase 2 → `/j/`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:33:20 -04:00
louispaulb
e50ea88c08 feat: unify vision on Gemini + port field tech scan/device into /j
- Invoice OCR migrated from Ollama (GPU-bound, local) to Gemini 2.5
  Flash via new targo-hub /vision/invoice endpoint with responseSchema
  enforcement. Ops VM no longer needs a GPU.
- Ops /j/* now has full camera scanner (TechScanPage) ported from
  apps/field with 8s timeout + offline queue + auto-link to Dispatch
  Job context on serial/barcode/MAC 3-tier lookup.
- New TechDevicePage reached via /j/device/:serial showing every
  ERPNext entity related to a scanned device: Service Equipment,
  Customer, Service Location, active Subscription, open Issues,
  upcoming Dispatch Jobs, OLT info.
- New docs/VISION_AND_OCR.md (full pipeline + §10 relationship graph
  + §8.1 secrets/rotation policy). Cross-linked from ARCHITECTURE,
  ROADMAP, HANDOFF, README.
- Nginx /ollama/ proxy blocks removed from both ops + field.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:26:01 -04:00
louispaulb
41d9b5f316 feat: flow editor, Gemini QR scanner with offline queue, dispatch planning v2
Major additions accumulated over 9 days — single commit per request.

Flow editor (new):
- Generic visual editor for step trees, usable by project wizard + agent flows
- PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain
- Drag-and-drop reorder via vuedraggable with scope isolation per peer group
- Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved)
- Variable picker with per-applies_to catalog (Customer / Quotation /
  Service Contract / Issue / Subscription), insert + copy-clipboard modes
- trigger_condition helper with domain-specific JSONLogic examples
- Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern
- Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js
- ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates
- depends_on chips resolve to step labels instead of opaque "s4" ids

QR/OCR scanner (field app):
- Camera capture → Gemini Vision via targo-hub with 8s timeout
- IndexedDB offline queue retries photos when signal returns
- Watcher merges late-arriving scan results into the live UI

Dispatch:
- Planning mode (draft → publish) with offer pool for unassigned jobs
- Shared presets, recurrence selector, suggested-slots dialog
- PublishScheduleModal, unassign confirmation

Ops app:
- ClientDetailPage composables extraction (useClientData, useDeviceStatus,
  useWifiDiagnostic, useModemDiagnostic)
- Project wizard: shared detail sections, wizard catalog/publish composables
- Address pricing composable + pricing-mock data
- Settings redesign hosting flow templates

Targo-hub:
- Contract acceptance (JWT residential + DocuSeal commercial tracks)
- Referral system
- Modem-bridge diagnostic normalizer
- Device extractors consolidated

Migration scripts:
- Invoice/quote print format setup, Jinja rendering
- Additional import + fix scripts (reversals, dates, customers, payments)

Docs:
- Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS,
  FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT,
  APP_DESIGN_GUIDELINES
- Archived legacy wizard PHP for reference
- STATUS snapshots for 2026-04-18/19

Cleanup:
- Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*)
- .gitignore now covers invoice preview output + nested .DS_Store

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 10:44:17 -04:00
louispaulb
607ea54b5c refactor: reduce token count, DRY code, consolidate docs
Backend services:
- targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons
  lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas,
  extract dispatch scoring weights, trim section dividers across 9 files
- modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(),
  consolidate DM query factory, fix duplicate username fill bug, trim headers
  (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%)

Frontend:
- useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into
  6 focused helpers (processOnlineStatus, processWanIPs, processRadios,
  processMeshNodes, processClients, checkRadioIssues)
- EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments

Documentation (17 → 13 files, -1,400 lines):
- New consolidated README.md (architecture, services, dependencies, auth)
- Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md
- Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md
- Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md
- Update ROADMAP.md with current phase status
- Delete CONTEXT.md (absorbed into README)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 08:39:58 -04:00
louispaulb
73691668d3 feat: tech mobile view integrated into ops app at /j, unassign confirmation
Tech mobile view (erp.gigafibre.ca/ops/#/j):
- TechLayout with bottom nav tabs (tasks, scanner, diagnostic, more)
- TechTasksPage: rich header with tech name/stats, job cards with
  priority dots, time, location, duration badges, bottom sheet detail
  with En route/Terminer buttons + scanner/detail access
- TechJobDetailPage: editable fields, equipment list, GPS navigation
- TechScanPage: device lookup by SN/MAC, create/link to job
- TechDiagnosticPage: speed test + host reachability checks
- Route /j replaces legacy dispatch-app tech view

Dispatch unassign confirmation:
- Dialog appears when unassigning published or in-progress jobs
- Warns that tech has already received the task
- Cancel/Confirm flow prevents accidental removal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 08:26:26 -04:00
louispaulb
8fc722acdf feat(field): job detail page with equipment management and inline editing
- New JobDetailPage: full-screen job view with editable fields (subject,
  type, priority, time, duration, description), status transitions
  (en route / terminer / rouvrir), GPS navigation to service location
- Equipment section: list equipment at location, add via scanner/search/create
- TasksPage: jobs now navigate to detail page instead of inline expand,
  quick status buttons remain accessible from the list
- Offline support: all edits queued when offline, cached job data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 07:21:38 -04:00
louispaulb
922572653a docs: comprehensive ecosystem overview for dev/sysadmin onboarding
Complete synthesis covering infrastructure, ERPNext data model, ops app
architecture (40 composables, 12 pages, dispatch features), targo-hub
modules, migration pipeline, integrations, and deployment procedures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 22:49:21 -04:00
louispaulb
0c77afdb3b feat: dispatch planning mode, offer pool, shared presets, recurrence selector
- Planning mode toggle: shift availability as background blocks on timeline
  (week view shows green=available, yellow=on-call; month view per-tech)
- On-call/guard shift editor with RRULE recurrence on tech schedules
- Uber-style job offer pool: broadcast/targeted/pool modes with pricing,
  SMS notifications, accept/decline flow, overload detection alerts
- Shared resource group presets via ERPNext Dispatch Preset doctype
  (replaces localStorage, shared between supervisors)
- Google Calendar-style RecurrenceSelector component with contextual
  quick options + custom RRULE editor, integrated in booking overlay
  and extra shift editor
- Remove default "Repos" ghost chips — only visible in planning mode
- Clean up debug console.logs across API, store, and page layers
- Add extra_shifts Custom Field on Dispatch Technician doctype

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 22:44:18 -04:00
louispaulb
a9f8d0c7bf perf: memoize dispatch timeline segments + load/capacity as computed Maps
Before: techDayJobsWithTravel(tech), periodLoadH(tech), techPeriodCapacityH(tech)
were called as functions in the template v-for — recalculated on EVERY render
for every tech (10 techs × 3 functions = 30 expensive recomputations per render).

After: Pre-computed as Vue computed Maps (segmentsMap, loadMap, capMap) that
only recompute when their reactive dependencies actually change. Template
reads from map[tech.id] — instant O(1) lookup, no recalculation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 18:24:16 -04:00
louispaulb
fd326ac52e perf: parallelize dispatch API fetches + add sales_order/order_source fields
Dispatch performance:
- Replace sequential batch fetches (batches of 15, one after another)
  with full parallel Promise.all — all doc fetches fire simultaneously
- With 20 jobs: was ~3 sequential round-trips, now ~2 (1 list + 1 parallel)

Order traceability:
- Add sales_order (Link) and order_source (Select) fields to Dispatch Job
- checkout.js sets order_source='Online' + sales_order link on job creation
- acceptance.js sets order_source='Quotation' on quotation-sourced jobs
- Store maps new fields: salesOrder, orderSource

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 18:07:14 -04:00
louispaulb
c6b2dd1491 refactor: extract composables from 5 largest files — net -1950 lines from main components
DispatchPage.vue: 1320→1217 lines
  - Extract SbModal.vue + SbContextMenu.vue reusable components
  - Extract useAbsenceResize composable
  - Extract dispatch constants to config/dispatch.js

ProjectWizard.vue: 1185→673 lines (-43%)
  - Extract useWizardPublish composable (270-line publish function)
  - Extract useWizardCatalog composable
  - Extract wizard-constants.js (step labels, options, categories)

SettingsPage.vue: 1172→850 lines (-27%)
  - Extract usePermissionMatrix composable
  - Extract useUserGroups composable
  - Extract useLegacySync composable

ClientDetailPage.vue: 1169→864 lines (-26%)
  - Extract useClientData composable (loadCustomer broken into sub-functions)
  - Extract useEquipmentActions composable
  - Extract client-constants.js + erp-pdf.js utility

checkout.js: 639→408 lines (-36%)
  - Extract address-search.js module
  - Extract otp.js module
  - Extract email-templates.js module
  - Extract project-templates.js module
  - Add erpQuery() helper to DRY repeated URL construction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 17:57:24 -04:00
louispaulb
320655b0a0 refactor: major cleanup — remove dead dispatch app, commit all backend code, extract client composables
- Remove apps/dispatch/ (100% replaced by ops dispatch module, unmaintained)
- Commit services/targo-hub/lib/ (24 modules, 6290 lines — was never tracked)
- Commit services/docuseal + services/legacy-db docker-compose configs
- Extract client app composables: useOTP, useAddressSearch, catalog data, format utils
- Refactor CartPage.vue 630→175 lines, CatalogPage.vue 375→95 lines
- Clean hardcoded credentials from config.js fallback values
- Add client portal: catalog, cart, checkout, OTP verification, address search
- Add ops: NetworkPage, AgentFlowsPage, ConversationPanel, UnifiedCreateModal
- Add ops composables: useBestTech, useConversations, usePermissions, useScanner
- Add field app: scanner composable, docker/nginx configs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 17:38:38 -04:00
louispaulb
838f8dcd8d docs: complete architecture — service map, dependencies, data flows
Full system documentation: Docker containers, request flows,
targo-hub endpoints, GenieACS integration, ops app structure,
external service dependencies, and device diagnostics data flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:29:05 -04:00
louispaulb
bfffed2b41 feat: ONT diagnostics — grouped mesh topology, signal RSSI, management link
- EquipmentDetail: collapsible node groups (clients grouped by mesh node)
- Signal strength as RSSI % (0-255 per 802.11-2020) with 10-tone color scale
- Management IP clickable link to device web GUI (/superadmin/)
- Fibre status compact top bar (status + Rx/Tx power when available)
- targo-hub: WAN IP detection across all VLAN interfaces
- targo-hub: full WiFi client count (direct + EasyMesh mesh repeaters)
- targo-hub: /devices/:id/hosts endpoint with client-to-node mapping
- ClientsPage: start empty, load only on search (no auto-load all)
- nginx: dynamic ollama resolver (won't crash if ollama is down)
- Cleanup: remove unused BillingKPIs.vue and TagInput.vue
- New docs and migration scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:26:14 -04:00
louispaulb
fa37426f34 docs: data structure foundation for lead-to-service pipeline
ERPNext doctype relationships and field additions:
- Customer → Service Location → Subscriptions + Equipment
- Project with installation task templates
- Dispatch Job linked to project and location
- New custom fields: wifi_ssid/password, sip_username/password,
  gpon_serial, cwmp_serial, radius credentials on Service Equipment

Order wizard flow (website + agent):
- Address check → plan selection → customer info → dates → payment
- Atomic creation: Customer + Location + Subs + Equipment + Project
  + Tasks + Dispatch Job + fibre port reservation

Existing field tech app inventory:
- TasksPage (jobs + tickets), ScanPage (photo/live/manual),
  DevicePage (detail + customer link), DiagnosticPage (speed test)
- useScanner composable (3-strip photo scan for multi-barcode)
- Offline queue with replay

Migration gaps identified: WiFi/VoIP provisioning data,
RADIUS credentials, product catalog, fibre→RQA address matching

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 09:37:38 -04:00
louispaulb
e9324b45bc docs: complete customer flow architecture (lead → live service)
End-to-end design covering:
- Address fuzzy search → fibre availability (16,056 entries, 30 OLTs)
- Plan selection + pricing engine (FTTH 25M-1.5G, TV, Phone, combos)
- Checkout → Stripe → n8n automation pipeline
- ERPNext: Customer → Service Location → Subscriptions → Equipment
- Project/task auto-creation for installation workflow
- Dispatch job with preferred dates and tag-based tech matching
- Field tech barcode scanning → Service Equipment creation
- ACS auto-provisioning on device bootstrap (WiFi, VoIP, firmware)
- Lead funnel for non-covered addresses (phone-only, business)
- Customer portal (invoices, WiFi management, tickets)
- Full product catalog mapped from legacy (Internet/TV/Phone/discounts)
- 5-sprint build order with dependencies

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 09:28:20 -04:00
louispaulb
0536e04c86 feat: extract GenieACS WiFi/VoIP provisioning data from MariaDB
Found GenieACS MariaDB at 10.100.80.100 (NOT 10.5.14.21 as
configured in ext scripts — that IP was stale/blocked).

Provisioning data:
- 1,713 WiFi entries (858 unique Deco MACs → SSID/password)
- 797 VoIP entries (469 unique RCMG ONT serials → SIP creds)
- WiFi keyed by Deco MAC (403F8C OUI), VoIP by ONT serial

Complete chain verified:
  ONT serial (RCMG) → fibre table (OLT/slot/port)
                     → device table (delivery_id)
                     → delivery (account_id → ERPNext customer)
                     → VoIP provisioning (SIP credentials)
                     → WiFi provisioning (via linked Deco MAC)

Reconciliation: 2,499 RCMG serials addressable, 2,003 have
full fibre+device chain, 282 have VoIP provisioning attached.
3,185 TPLG serials, 2,935 in both fibre and device tables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:49:26 -04:00
louispaulb
231bb6fbcc feat: complete device matching analysis (legacy ↔ GenieACS ↔ ERPNext)
Full data export and cross-reference analysis:
- 7,550 GenieACS devices with IPs, deviceId, tags
- 6,720 legacy devices (raisecom, tplink, onu categories)
- 16,056 fibre table entries (OLT frame/slot/port/ontid, VLANs)
- 8,434 legacy services linked to devices

Key finding: CWMP serial ≠ physical serial. Only 22/7,550 devices
are tagged with their physical serial (RCMG/TPLG). Raisecom MAC
is extractable from CWMP serial suffix. TP-Link CWMP serial = sticker
serial for ONT models.

Matching strategy documented: tag-based, MAC-based, OLT port-based.
Recommends bulk tagging via OLT query as first step.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:08:57 -04:00
louispaulb
8ba73251f3 feat: full GenieACS config export (provisions, ext scripts, fleet data)
Complete backup of all GenieACS ACS configuration:
- 24 provision scripts (default, inform, bootstrap, firmware upgrades,
  per-model configs for HT803G, HT502, HT812, Deco, XX230v, XX430v, XX530v)
- 25 presets (trigger rules mapping events to provisions)
- 6 ext scripts (provisioning.js, wifi.js, voip.js — query MariaDB
  for per-device WiFi SSID/password and VoIP credentials)
- 12 firmware images catalogued (HT502, HG8245, HT803G-W/WS2, HT812, Deco)
- 7,550 device fleet snapshot (4,035 online, 53.4% online rate)
- GenieACS env config (MongoDB at 10.5.2.116, ext dir, JWT secret)

Fleet breakdown:
- Device2 (TP-Link Deco): 4,051 units (74% online) — bulk of fleet
- HT803G (Raisecom): 2,833 units (33% online) — legacy ONTs
- DISCOVERYSERVICE: 156 ghost entries (0% online)
- Grandstream phones: GXP2130/2160/1630, HT502/812

Key finding: ext scripts use MariaDB (10.5.14.21) for WiFi/VoIP
provisioning data (SSID, passwords, SIP credentials per serial).
This data must be migrated to ERPNext or a new provisioning DB
for Oktopus.

Custom fork: @genieacs/genieacs-targo v1.2.8-targo.3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 21:08:51 -04:00
louispaulb
56ad97bc71 feat: GenieACS config export + TR-069 to TR-369 migration plan
- Add /acs/export endpoint: dumps all provisions, presets, virtual
  params, files metadata in one call (insurance policy for migration)
- Add /acs/provisions, /acs/presets, /acs/virtual-parameters, /acs/files
- Shell script export_genieacs.sh for offline full backup
- TR069-TO-TR369-MIGRATION.md: phased migration plan from GenieACS
  to Oktopus with parallel run, provision mapping, CPE batching

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 21:03:41 -04:00
louispaulb
ea71eec194 feat: GenieACS NBI integration for live CPE/ONT status
targo-hub:
- Add /devices/* endpoints proxying GenieACS NBI API (port 7557)
- /devices/summary — fleet stats (online/offline by model)
- /devices/lookup?serial=X — find device by serial number
- /devices/:id — device detail with summarized parameters
- /devices/:id/tasks — send reboot, getParameterValues, refresh
- /devices/:id/faults — device fault history
- GENIEACS_NBI_URL configurable via env var

ops app:
- New useDeviceStatus composable for live ACS status
- Equipment chips show green/red online dot from GenieACS
- Enriched tooltips: firmware, WAN IP, Rx/Tx power, SSID, last inform
- Right-click context menu: Reboot device, Refresh parameters
- Signal quality color coding (Rx power dBm thresholds)
- 1-minute client-side cache to avoid hammering NBI API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 20:55:13 -04:00
louispaulb
a2c59d6528 feat: ticket lazy-load, inline editing, search improvements
- Tickets: load 10 initially, "Voir tous les tickets" expands to 500
- Inline editing for ticket status and priority (dblclick → select)
- Search: Enter key triggers immediate search and navigates to result
- Search: Arrow key navigation for result highlighting
- Reset expanded state on customer navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:43:25 -04:00
louispaulb
4693bcf60c feat: telephony UI, performance indexes, Twilio softphone, lazy-load invoices
- Add PostgreSQL performance indexes migration script (1000x faster queries)
  Sales Invoice: 1,248ms → 28ms, Payment Entry: 443ms → 31ms
  Indexes on customer/party columns for all major tables
- Disable 3CX poller (PBX_ENABLED flag, using Twilio instead)
- Add TelephonyPage: full CRUD UI for Routr/Fonoster resources
  (trunks, agents, credentials, numbers, domains, peers)
- Add PhoneModal + usePhone composable (Twilio WebRTC softphone)
- Lazy-load invoices/payments (initial 5, expand on demand)
- Parallelize all API calls in ClientDetailPage (no waterfall)
- Add targo-hub service (SSE relay, SMS, voice, telephony API)
- Customer portal: invoice detail, ticket detail, messages pages
- Remove dead Ollama nginx upstream

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 13:59:59 -04:00
louispaulb
413e15b16c refactor: strip CUST- prefix, use bank reference as customer ID
Customer IDs are now the raw legacy customer_id (bank payment reference):
  LPB4, 114796350603272, DOMIL5149490230

New customers: C + 14 digits sequential (C10000000000001)
No collision with existing 15-digit bank references (gap at 10^13).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 18:02:08 -04:00
louispaulb
4a8718f67c feat: subscription reimport, customer/doctype ID rename, zero-padded format
- Switch Ops data source from Subscription to Service Subscription (source of truth)
- Reimport 39,630 native Subscriptions from Service Subscription data
- Rename 15,302 customers to CUST-{legacy_customer_id} (eliminates hex UUIDs)
- Rename all doctypes to zero-padded 10-digit numeric format:
  SINV-0000001234, PE-0000001234, ISS-0000001234, LOC-0000001234,
  EQP-0000001234, SUB-0000001234, ASUB-0000001234
- Fix subscription pricing: LPB4 now correctly shows 0$/month
- Update ASUB- prefix detection in useSubscriptionActions.js
- Add reconciliation, reimport, and rename migration scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 17:17:23 -04:00
louispaulb
7d7b4fdb06 feat: nested tasks, project wizard, n8n webhooks, inline task editing
Major dispatch/task system overhaul:
- Project templates with 3-step wizard (choose template → edit steps → publish)
- 4 built-in templates: phone service, fiber install, move, repair
- Nested task tree with recursive TaskNode component (parent_job hierarchy)
- n8n webhook integration (on_open_webhook, on_close_webhook per task)
- Inline task editing: status, priority, type, tech assignment, tags, delete
- Tech assignment + tags from ticket modal → jobs appear on dispatch timeline
- ERPNext custom fields: parent_job, on_open_webhook, on_close_webhook, step_order
- Refactored ClientDetailPage, ChatterPanel, DetailModal, dispatch store
- CSS consolidation, dead code cleanup, composable extraction
- Dashboard KPIs with dispatch integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 13:01:20 -04:00
louispaulb
101faa21f1 feat: inline editing, search, notifications + full repo cleanup
- InlineField component + useInlineEdit composable for Odoo-style dblclick editing
- Client search by name, account ID, and legacy_customer_id (or_filters)
- SMS/Email notification panel on ContactCard via n8n webhooks
- Ticket reply thread via Communication docs
- All migration scripts (51 files) now tracked
- Client portal and field tech app added to monorepo
- README rewritten with full feature list, migration summary, architecture
- CHANGELOG updated with all recent work
- ROADMAP updated with current completion status
- Removed hardcoded tokens from docs (use $ERP_SERVICE_TOKEN)
- .gitignore updated (docker/, .claude/, exports/, .quasar/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 07:34:41 -04:00
louispaulb
26a0077015 fix: route API + Ollama calls through ops-frontend nginx proxy
All /api/ and /ollama/ requests now go through the ops base path
(/ops/api/... and /ops/ollama/...) so Traefik routes them to
ops-frontend nginx, which injects the ERPNext token server-side.
Token is never exposed in the browser.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:00:14 -04:00
louispaulb
2453bc6ef2 feat: Ollama Vision OCR for bill/invoice scanning
- Ollama container running llama3.2-vision:11b on server
- OCR page in ops app: camera/upload → Ollama extracts vendor, date,
  amounts, line items → editable form → create Purchase Invoice
- nginx proxies /ollama/ to Ollama API (both ops + field containers)
- Added createDoc to erp.js API layer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:57:21 -04:00
louispaulb
dc63462c0c fix: client detail page reloads when navigating between customers
Extract data loading into loadCustomer() function and watch props.id
for changes. Previously only ran in onMounted — navigating between
clients showed stale data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:51:34 -04:00
louispaulb
1ed86e37ad fix: server-side API token injection + ticket modal empty state
- Move ERPNext API token from JS bundle to nginx proxy_set_header
  (token only lives on server, never in client code)
- Switch ops + field apps from auth.targo.ca to id.gigafibre.ca SSO
- Fix "Aucun contenu" showing on tickets that have comments but no
  description (check comments.length in v-if condition)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:31:58 -04:00
louispaulb
11cd38f93c feat: add field tech app — barcode scanner, tasks, diagnostics, offline
Mobile-first Quasar PWA for field technicians at erp.gigafibre.ca/field/:
- Multi-barcode scanner (photo + live + manual) with device lookup
- Tasks page: today's Dispatch Jobs + assigned tickets
- Diagnostic: speed test, HTTP resolve, batch service check
- Device detail with customer linking
- Offline support: IndexedDB queue, API cache, auto-sync
- Standalone nginx container with Traefik StripPrefix + Authentik SSO

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:00:44 -04:00
louispaulb
13dcd4bf77 feat: add ops app + CONTEXT.md, simplify URL to /ops/
Ops app (Vue/Quasar PWA) with dispatch V2 integration, tag system,
customer 360, tickets, and dashboard. Served via standalone nginx
container at erp.gigafibre.ca/ops/ with Traefik StripPrefix + Authentik SSO.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:41:58 -04:00
louispaulb
08cf1c94e3 feat: 29K customer memos imported as Comments with real dates
- 29,178 account_memo → Comment on Customer
- Timestamps converted from unix to datetime
- Author mapped from staff_id → User email
- Visible in Customer page comment section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:43:57 -04:00
louispaulb
c6b5aa8f61 feat: 99K payments imported with invoice references
- 99,839 Payment Entries (24 months, excl credits)
- 120,130 Payment-Invoice references (payment_item → Sales Invoice)
- Modes: Bank Draft (PPA/direct), Credit Card, Cheque, Cash
- legacy_payment_id custom field for traceability
- Creation dates set from legacy timestamps
- 0 errors

Full financial chain: Customer → Subscription → Invoice → Payment → Bank ref (PPA)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:33:35 -04:00
louispaulb
5640063bd0 fix: correct creation/modified dates from unix timestamps
- 129,078 Issues: creation = ticket.date_create, modified = ticket.last_update
- 115,721 Invoices: creation = invoice.date_orig
- 15,059 Customers: creation = account.date_orig, modified = account.date_last

All timestamps now show real legacy dates instead of import date.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:20:47 -04:00
louispaulb
4f74376412 feat: complete data mirror — all customers + 115K invoices
- 8,636 terminated customers imported (disabled=1, terminate reason/company/notes preserved)
- Total customers: 15,303 (100% of legacy)
- 33,131 Subscription.party links fixed (CUST-xxx)
- 115,721 Sales Invoices (24 months) + 658K line items
- Custom field: Sales Invoice.legacy_invoice_id
- All invoices as Draft (not submitted, not sent)

Customer lifecycle preserved:
  Active → services, subscriptions, invoices
  Terminated → disabled=1, customer_details has departure reason/competitor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:09:16 -04:00
louispaulb
22377bb381 feat: fix all data relationships + PPA reference numbers
- fix_issue_owners.py: 53K Issues linked to creator (owner) + 55K to assignee (_assign)
- fix_issue_cust2.py: 47K Issues linked to Customer via legacy_account_id
- fix_sub_address.py: 21K Subscriptions linked to service Address
- customer_pos_id set to legacy PPA reference (15-digit bank number) on all 6,667 Customers
- Subscription custom fields: service_address (Link→Address), service_location (Link→Service Location)
- Fiscal Year 2025-2026 created (Jul 1 2025 → Jun 30 2026)

Relationships now complete:
  Customer → Address (N) → Subscription (N) → Item (plan + speeds)
  Customer → Contact (N) → email/phone
  Customer → Issue (N) → parent_incident → child Issues
  Issue → owner (User who created) + _assign (User responsible)
  Subscription → service_address → specific installation address
  Customer.customer_pos_id = PPA bank reference number

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:45:51 -04:00
louispaulb
ac9b367334 feat: Phase 7 — 45 ERPNext Users from legacy staff
- 45 users created with Authentik SSO (no password)
- Roles assigned: System Manager, Support Team, Sales/Accounts
- Service accounts skipped (admin, tech, dev, inventaire, agent)
- Email = Authentik identity link

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:13:31 -04:00
louispaulb
7a15bfd600 feat: Phase 6 — 242K tickets migrated as Issues with parent/child
- 38 Issue Types from ticket_dept
- 242,605 Issues created (open + closed)
- 25,805 parent/child links (incident pattern)
- Custom fields: parent_incident, is_incident, affected_clients, impact_zone, service_location, legacy_ticket_id
- Communications deferred (778K closed ticket messages — import separately)
- 0 staff→user mapped (ERPNext users need to be created/linked)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:06:58 -04:00
louispaulb
571f89976d feat: Phase 5 opening balance + AR analysis
- Journal Entry draft created with 1,918 customer balance lines
- AR analysis: $423K monthly billing, $77.96 avg/client, $62K aging 90j+
- Temporary Opening equity account created
- Scheduler remains PAUSED

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 14:47:18 -04:00
louispaulb
93dd7a525f feat: migration legacy → ERPNext phases 1-4 complete
Phase 1: 833 Items + 34 Item Groups + custom fields (ISP speeds, RADIUS, legacy IDs)
Phase 2: 6,667 Customers + Contacts + Addresses via direct PG (~30s)
Phase 3: Tax template QC TPS+TVQ + 92 Subscription Plans
Phase 4: 21,876 Subscriptions with RADIUS data

CRITICAL: ERPNext scheduler is PAUSED — do not reactivate without explicit go.

Includes:
- ARCHITECTURE-COMPARE.md: full schema mapping legacy vs ERPNext
- CHANGELOG.md: detailed migration log
- MIGRATION-PLAN.md: strategy and next steps
- scripts/migration/: idempotent Python scripts (direct PG method)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 14:35:02 -04:00
louispaulb
2e55a7d031 security: remove exposed credentials, add .gitignore, harden infra
- Replace hardcoded ERPNext token and Twilio SID with $VAR placeholders
- Add .gitignore to exclude .env files, node_modules, build output
- Untrack apps/website/.env (contained Supabase key)
- Remove git.gigafibre.ca references (use git.targo.ca only)

Server-side (applied live):
- Traefik: disable dashboard, close port 8080
- Oktopus: add Authentik forwardAuth middleware
- Log level: DEBUG → WARN

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 09:17:33 -04:00
louispaulb
04dc0ceb14 refactor: monorepo structure — apps/dispatch, apps/website, erpnext/
- Merged dispatch-app (17 commits) into apps/dispatch/
- Merged site-web-targo (4 commits) into apps/website/
- Renamed scripts/ → erpnext/
- Removed empty doctypes/
- Updated README with monorepo layout and Gigafibre branding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:10:15 -04:00
louispaulb
6620652900 merge: import site-web-targo into apps/website/ (4 commits preserved)
Integrates www.gigafibre.ca (React/Vite) into the monorepo.
Full git history accessible via `git log -- apps/website/`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:09:15 -04:00
louispaulb
7da22ff132 merge: import dispatch-app into apps/dispatch/ (17 commits preserved)
Integrates the Dispatch PWA (Vue/Quasar) into the gigafibre-fsm monorepo.
Full git history accessible via `git log -- apps/dispatch/`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:08:51 -04:00
louispaulb
4c64e218a0 docs: infrastructure complète — schéma serveur, DNS, auth, APIs, gotchas
Guide de référence et de transfert couvrant toute l'infra Gigafibre:
Traefik, Authentik SSO, ERPNext, Dispatch PWA, n8n, Mailjet, Twilio,
DNS Cloudflare, Docker compose, build/deploy, pièges connus.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:03:42 -04:00
louispaulb
fe8e3116bc docs: competitive analysis — Gaiia, Odoo, Zuper, Salesforce, ServiceTitan
Full feature matrix comparing 6 FSM platforms with Gigafibre.
Gaiia (YC, $13.2M) is the primary comparable — ISP-specific OSS/BSS.

Key insights:
- Gaiia charges per-subscriber; Gigafibre is self-hosted (free)
- Our dispatch UX already exceeds Gaiia's public features
- Biggest gaps: customer portal, online checkout, mobile tech app
- Quick wins: auto travel time, tech status updates, SMS notifications

Priority roadmap ordered by ROI for a 2-10 tech ISP operation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:04:05 -04:00
louispaulb
49494cf1a7 Initial commit: FSM data model, architecture docs, setup scripts
Data model inspired by Odoo OCA Field Service + Salesforce FS patterns.
Adapted for small ISP/telecom (Gigafibre) running ERPNext.

Doctypes: Service Location, Service Equipment, Service Subscription
+ child tables for equipment history, checklists, photos, materials
+ extended Dispatch Job with customer/location/equipment links

Docs: architecture overview, tech stack, auth flow, industry comparison, roadmap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:02:25 -04:00
louispaulb
6fc8a2d37f refactor: externalize ERP service token via VITE_ERP_TOKEN env var
Token is no longer hardcoded in source — injected at build time.
Build with: VITE_ERP_TOKEN="key:secret" npx quasar build
Prevents accidental token invalidation and keeps secrets out of git.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:39:41 -04:00
louispaulb
1263786b90 fix: update service token + fix API proxy routing
- Regenerated Admin API token (old one was invalidated by generate_keys)
- Traefik: separate routers for app (with Authentik) and /api/ (no auth)
- Nginx proxy: use container IP (cross-compose DNS doesn't resolve names)
- /outpost.goauthentik.io/ route for Authentik callbacks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:39:11 -04:00
louispaulb
7ef22873f0 fix: handle Authentik session expiry in SPA
- authFetch uses redirect:'manual' to detect 302 from Authentik
- If session expired (302/401/opaqueredirect), reload page to trigger
  Traefik forwardAuth → Authentik re-login flow
- Logout redirects to Authentik invalidation flow
- App.vue calls checkSession on mount to populate user identity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:34:39 -04:00
louispaulb
f1faffeab9 feat: switch Dispatch auth to Authentik forwardAuth
- Remove login form from App.vue (Authentik handles auth at Traefik level)
- Simplify auth store: no more checkSession/generate_keys complexity
- All ERPNext API calls use a service token (reliable, no CORS issues)
- User identity provided by Authentik X-authentik-email header
- Logout redirects to Authentik end-session URL
- Removed: login(), generate_keys, cookie fallback, token localStorage

Infrastructure:
- Created Authentik Proxy Provider for dispatch.gigafibre.ca
- Added to embedded outpost
- Applied authentik@file middleware to dispatch Traefik router
- Also removed unused Gitea (git.gigafibre.ca) containers + volumes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:33:09 -04:00
louispaulb
6d8339fa16 fix: map markers zoom drift — fixed-size container + center anchor
- All elements (SVG ring + avatar + badge) inside a fixed-size
  container (45x45px) with absolute positioning
- Avatar centered with calculated offset, ring fills container
- Mapbox marker anchor changed from 'bottom' to 'center'
- No more variable margins causing offset drift on zoom

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:08:36 -04:00
louispaulb
6f901f911c feat: SVG circular progress ring on map tech markers
Replace faint linear bars with SVG ring around avatar:
- Outer arc (faded): planned load / 8h capacity, color-coded
  green→yellow→orange→red based on load percentage
- Inner arc (solid green): jobs completed / total jobs
- Ring uses stroke-dasharray for clean arcs with round caps
- Tooltip: "Name — 2/5 jobs (3.0h / 7.0h)"
- Crew badge (purple "2") positioned on avatar corner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:05:32 -04:00
louispaulb
f7fea2b8e5 feat: dual progress bar on map markers (load + completion)
- Background bar (faded): planned hours / 8h capacity
  Color codes: green <4h, yellow 4-6h, orange 6-7h, red 7h+
- Foreground bar (solid green): completed hours / 8h
  Shows real-time job completion progress
- Tooltip: "Name — X.Xh complété / X.Xh planifié"
- Both bars stacked with absolute positioning for clean overlay

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:22:11 -04:00
louispaulb
15813e6caf feat: map markers — workload progress bar + crew group badge
- Progress bar under each tech avatar showing daily load (0-8h)
  Green (<4h) → Yellow (4-6h) → Orange (6-8h) → Red (8h+)
  Includes both primary queue and assistant jobs
- Crew badge: purple circle with count (e.g. "2") when tech has
  assistants on today's jobs — indicates grouped team
- Tooltip shows "Name — X.Xh / 8h"
- Marker now uses gpsCoords || coords for visibility check
  (fixes techs with GPS but no static coords)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:16:56 -04:00
louispaulb
af42c6082e feat: auth gate, GPS hybrid tracking, tech CRUD modal, ERPNext API proxy
Authentication:
- Add App.vue login gate (v-if auth.loading / v-else-if !auth.user / router-view)
- Fix auth.checkSession with try/finally to always reset loading
- Fix generate_keys method name for Frappe v16 (generate_keys, not generate_keys_for_api_user)
- Auto-generate API token on cookie-based auth (Authentik SSO support)
- Remove duplicate checkSession from DispatchV2Page (was causing infinite mount/unmount loop)

GPS Tracking — Hybrid REST + WebSocket:
- Initial REST fetch per-device in parallel (Traccar API only supports one deviceId per request)
- WebSocket real-time updates via wss://dispatch.gigafibre.ca/traccar/api/socket
- Auto-fallback to 30s polling if WebSocket fails, with exponential backoff reconnect
- Module-level guards (__gpsStarted, __gpsPolling) to prevent loops on component remount
- Only update tech.gpsCoords when value actually changes (prevents unnecessary reactive triggers)

Tech Management (GPS Modal):
- Add/delete technicians directly from GPS modal → persists to ERPNext
- Inline edit: double-click name to rename, phone field, status select
- Auto-generate technician_id (TECH-N+1)
- Unlink jobs before delete to avoid ERPNext LinkExistsError
- Added phone/email custom fields to Dispatch Technician doctype

Infrastructure:
- Nginx proxy: /api/ → ERPNext (same-origin, eliminates all CORS issues)
- Nginx proxy: /traccar/ WebSocket support (Upgrade headers, 86400s timeout)
- No-cache headers on index.html and sw.js for instant PWA updates
- BASE_URL switched to empty string in production (same-origin via proxy)

Bug fixes:
- ERPNext Number Card PostgreSQL fix (ORDER BY on aggregate queries)
- Traccar fetchPositions: parallel per-device calls (API ignores multiple deviceId params)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:02:04 -04:00
louispaulb
f1badea201 fix: add watcher for GPS position updates on map markers
Technician GPS positions from Traccar were being fetched and stored
correctly every 30s but drawMapMarkers() was never triggered on
gpsCoords change, so markers stayed at their initial position.

Added a deep watcher on store.technicians[].gpsCoords in useMap.js
that calls drawMapMarkers() whenever any technician's GPS position
is updated by pollGps().

Also includes traccar.js API module and dispatch store GPS polling
(pollGps / startGpsPolling / stopGpsPolling).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 20:17:13 -04:00
louispaulb
859f043bb2 Refactor: extract autoDispatch, serializeAssistants, store assign logic
- Extract useAutoDispatch.js (autoDistribute + optimizeRoute)
- Add serializeAssistants() to useHelpers — removes 6 duplications
- Move smartAssign/fullUnassign into Pinia store
- Add drag-and-drop on dispatch criteria modal
- DispatchV2Page.vue: 1463 → 1385 lines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:25:33 -04:00
louispaulb
a5822f7a5b Add deploy-fast.sh — local build + docker cp (~5s vs ~30s)
No Docker image rebuild needed. Builds PWA locally with npx quasar
then copies dist/pwa directly into the ERPNext frontend container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:12:07 -04:00
louispaulb
632e4ae0d1 Refactor: modular architecture — extract composables & components
- Extract useDragDrop.js (drag/drop, block move, resize)
- Extract useSelection.js (lasso, multi-select, hover linking)
- Extract WeekCalendar.vue, MonthCalendar.vue, RightPanel.vue
- DispatchV2Page.vue: 3018 → 1438 lines (orchestration only)
- Remove <style scoped> — styles cascade to child components
- Add .dockerignore (build context 214MB → 112KB)
- Add infra/ with docker-compose reference and .env.example

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:08:56 -04:00
louispaulb
b90db4673a Fix: restore techCtx and openTechCtx lost during map extraction
These were accidentally deleted when removing ~576 lines of inline map code.
Caused ReferenceError preventing the app from loading data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:22:19 -04:00
louispaulb
ec385c99d0 Add ARCHITECTURE.md — full project documentation
Covers: stack, directory structure, routes, ERPNext doctypes,
PostgreSQL extensions, features, component communication,
planned modules, and deploy instructions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:11:40 -04:00
louispaulb
e2b775c077 Clean up: remove duplicate TagInput, delete quasar-migration branch
- Removed src/components/shared/TagInput.vue (exact duplicate of src/components/TagInput.vue)
- Deleted feature/quasar-migration branch from local and remote
- Verified build + deploy: design intact

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:11:40 -04:00
louispaulb
1b0fc89304 Initial commit — OSS/BSS Field Dispatch app
Current state: custom CSS + vanilla Vue components
Architecture: modular with composables, provide/inject pattern
Ready for progressive migration to Quasar native components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:11:40 -04:00
752 changed files with 218750 additions and 90 deletions

3
.env
View File

@ -1,3 +0,0 @@
VITE_SUPABASE_PROJECT_ID="rddrjzptzhypltuzmere"
VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJkZHJqenB0emh5cGx0dXptZXJlIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA4MTY4NTYsImV4cCI6MjA4NjM5Mjg1Nn0.EluFlKBze8BYM6AFx88G7kt21EvR18EI3uw1zgCXVzs"
VITE_SUPABASE_URL="https://rddrjzptzhypltuzmere.supabase.co"

76
.gitignore vendored
View File

@ -1,25 +1,53 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment files (may contain secrets)
.env
.env.local
.env.*.local
apps/**/.env
apps/**/.env.local
# Dependencies
node_modules/
# Build output
dist/
build/
docker/
# Quasar dev cache
apps/**/.quasar/
# Claude workspace (local only)
.claude/
# Data exports (may contain PII)
exports/
# OS
.DS_Store
**/.DS_Store
Thumbs.db
# Generated invoice/quote previews (output of setup_invoice_print_format.py
# + test_jinja_render.py). Keep sources (*.jinja) and final references
# (docs/assets/*.pdf when added intentionally), never ephemeral output.
invoice_preview*.pdf
scripts/migration/invoice_preview*.pdf
scripts/migration/invoice_preview*.html
scripts/migration/rendered_jinja_invoice*
scripts/migration/SINV-*.pdf
scripts/migration/ref_invoice.pdf
# IDE
.vscode/
.idea/
# Playwright snapshots
.playwright-mcp/
# Auto-generated backups from scripts/convert-html-to-unlayer.js
services/targo-hub/templates/*.bak-*.json
services/targo-hub/templates/*.bak-*.html
# Legacy refresh creds (prod-only, never commit)
.refresh.env
**/.refresh.env

194
README.md
View File

@ -1,73 +1,155 @@
# Welcome to your Lovable project
# Gigafibre FSM
## Project info
Gigafibre FSM is the operations platform for **Gigafibre** (consumer brand of TARGO Internet), a fiber ISP in Quebec. It replaces a legacy PHP/MariaDB billing system with ERPNext v16 + Vue 3/Quasar apps for ops, dispatch, field service, and customer self-service.
**URL**: https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID
## Repository Structure
## How can I edit this code?
There are several ways of editing your application.
**Use Lovable**
Simply visit the [Lovable Project](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and start prompting.
Changes made via Lovable will be committed automatically to this repo.
**Use your preferred IDE**
If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable.
The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating)
Follow these steps:
```sh
# Step 1: Clone the repository using the project's Git URL.
git clone <YOUR_GIT_URL>
# Step 2: Navigate to the project directory.
cd <YOUR_PROJECT_NAME>
# Step 3: Install the necessary dependencies.
npm i
# Step 4: Start the development server with auto-reloading and an instant preview.
npm run dev
```
gigafibre-fsm/
apps/
ops/ Targo Ops -- main operations PWA (Vue 3 / Quasar v2)
field/ Targo Field -- mobile app for technicians
client/ Gigafibre Portal -- customer self-service
website/ www.gigafibre.ca -- marketing site (React / Vite / Tailwind)
portal/ Customer portal deploy configs
services/
targo-hub/ Node.js API gateway (ERPNext, GenieACS, Twilio, Traccar)
modem-bridge/ SNMP/TR-069 bridge for CPE diagnostics
legacy-db/ Legacy MariaDB read-only access
docuseal/ Document signing service
erpnext/ Custom doctype setup scripts (setup_fsm_doctypes.py)
scripts/
migration/ 51 Python scripts for legacy-to-ERPNext data migration
bulk_submit.py, fix_ple_*.py/sh -- PostgreSQL patches, bulk ops
docs/ Architecture, infrastructure, migration, strategy docs
docker/ Docker compose fragments
patches/ ERPNext patches
```
**Edit a file directly in GitHub**
## Architecture
- Navigate to the desired file(s).
- Click the "Edit" button (pencil icon) at the top right of the file view.
- Make your changes and commit the changes.
```
Internet
|
96.125.196.67 (Proxmox VM, Ubuntu 24.04, Docker)
|
Traefik v2.11 (TLS via Let's Encrypt)
|
+----------+----------+----------+----------+----------+
| | | | | |
ERPNext Ops PWA Authentik n8n Website DocuSeal
erp. erp. auth. n8n. www. docs.
gigafibre gigafibre targo.ca giga giga gigafibre
.ca .ca/ops/ id.giga fibre fibre .ca
fibre.ca .ca .ca
|
targo-hub (API gateway, msg.gigafibre.ca)
|
+----------+----------+----------+----------+
| | | | |
GenieACS Twilio Mailjet Stripe Traccar
(TR-069) (SMS) (email) ($) (GPS)
|
modem-bridge (SNMP / TR-069 deep-dive for TP-Link)
```
**Use GitHub Codespaces**
## Services & Dependencies
- Navigate to the main page of your repository.
- Click on the "Code" button (green button) near the top right.
- Select the "Codespaces" tab.
- Click on "New codespace" to launch a new Codespace environment.
- Edit files directly within the Codespace and commit and push your changes once you're done.
| Service | URL | Stack | Purpose |
|---------|-----|-------|---------|
| ERPNext | erp.gigafibre.ca | Frappe v16, PostgreSQL | ERP / billing / ticketing — Source of Truth |
| Ops SPA | erp.gigafibre.ca/ops/ | Vue 3, Quasar v2, Pinia | Internal operations app (dispatch, clients, settings) |
| targo-hub | msg.gigafibre.ca | Node.js 20, Express | API gateway: SMS, SSE, AI, OAuth admin, Stripe webhooks, Traccar proxy |
| modem-bridge | internal | Node.js, Playwright | TR-181 deep-dive for TP-Link XX230v / Deco mesh |
| Authentik (staff) | auth.targo.ca | Python, PostgreSQL | SSO for ops + n8n + Gitea + ERPNext OAuth provider |
| Authentik (clients) | id.gigafibre.ca | Python, PostgreSQL | SSO for customer-facing portals |
| n8n | n8n.gigafibre.ca | Node.js | Workflow automation (campaigns, SMS, email) |
| Traefik | — | Go | Reverse proxy, TLS, forwardAuth middleware |
| Website | www.gigafibre.ca | React, Vite, Tailwind | Marketing site + address API |
| DocuSeal | sign.gigafibre.ca | Ruby on Rails | Contract e-signature |
| GenieACS | (external) | Node.js, MongoDB | TR-069 ACS for ONT/router fleet |
| Traccar | (external) | Java | GPS tracking for techs |
## What technologies are used for this project?
## ERPNext Custom Doctypes
This project is built with:
| Doctype | ID Pattern | Purpose |
|---------|-----------|---------|
| Service Location | LOC-##### | Customer premises (address, GPS, OLT port, network config) |
| Service Equipment | EQP-##### | Deployed hardware (ONT, router, TV box -- serial, MAC, IP) |
| Service Subscription | SUB-##### | Active service plans (speed, price, billing, RADIUS) |
| Dispatch Job | DJ-##### | Work orders with equipment, materials, checklist, photos, signature |
| Dispatch Technician | DT-##### | Tech profiles with GPS (Traccar), skills, color coding |
| Dispatch Tag | -- | Skill/service/region tags with levels (Fibre, TV, Telephonie, etc.) |
- Vite
- TypeScript
- React
- shadcn-ui
- Tailwind CSS
## Key Custom Fields
## How can I deploy this project?
| Doctype | Custom Fields |
|---------|---------------|
| Customer | `legacy_account_id`, `legacy_customer_id`, `ppa_enabled`, `stripe_id` |
| Item | `legacy_product_id`, `download_speed`, `upload_speed`, `olt_profile` |
| Subscription | `radius_user`, `radius_pwd`, `legacy_service_id` |
| Issue | `legacy_ticket_id`, `assigned_staff`, `issue_type`, `is_important`, `service_location` |
Simply open [Lovable](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and click on Share -> Publish.
## Tech Stack
## Can I connect a custom domain to my Lovable project?
**Frontend:** Vue 3, Quasar v2, Pinia, Vite, Mapbox GL JS
**Backend:** ERPNext v16 / Frappe (Python), PostgreSQL, Node.js (targo-hub)
**Infra:** Docker, Traefik v2.11, Authentik SSO, Proxmox
**Integrations:** Twilio (SMS), Mailjet (email), Stripe (payments), Traccar (GPS), GenieACS (TR-069), Gemini 2.5 Flash via targo-hub (vision/OCR — see [docs/features/vision-ocr.md](docs/features/vision-ocr.md))
Yes, you can!
## Data Volumes (migrated from legacy)
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
| Entity | Volume |
|--------|--------|
| Customers | 6,667 (active + terminated) |
| Subscriptions | 21,876 (with RADIUS credentials) |
| Sales Invoices | 115,000+ |
| Payments | 99,000+ (with invoice references) |
| Tickets (Issues) | 242,000+ (parent/child hierarchy) |
| Ticket Messages | 784,000+ |
| Devices | 7,600+ (ONT, router, TV box) |
| Service Locations | ~17,000 |
Read more here: [Setting up a custom domain](https://docs.lovable.dev/features/custom-domain#custom-domain)
## Development
```bash
# Ops app
cd apps/ops && npm install && npx quasar dev
# Website
cd apps/website && npm install && npm run dev
# targo-hub
cd services/targo-hub && npm install && npm run dev
# Deploy ops to production
cd apps/ops && bash deploy.sh
```
## Auth Pattern
Two parallel Authentik instances — **not** a migration in progress:
- **`auth.targo.ca`** — staff-facing. Protects `/ops/`, n8n, Gitea, the hub admin endpoints. Also acts as the OAuth provider for ERPNext sign-in. Lives on the prod box at `/opt/authentik` (managed by Targo's IT team).
- **`id.gigafibre.ca`** — customer-facing. Protects the client portal. Lives at `/opt/authentik-client` on the prod box.
Staff apps go through Traefik `forwardAuth` → Authentik outpost → cookie. The ops SPA reads `X-Authentik-Email` from the proxied header to identify the user, then maps Authentik groups to the in-app capability set (`useUserGroups.js`). All ERPNext API calls from targo-hub use `Authorization: token <ERP_SERVICE_TOKEN>` (Bearer from `.env`).
New users are added via **ops Settings → Utilisateurs → "Inviter"** which hits `POST /auth/users` on the hub. The hub creates the Authentik user, sets a temporary password, emails it via Mailjet, and creates the matching ERPNext System User in one round-trip. See [docs/SETUP.md](docs/SETUP.md) §6.
## Documentation
Start at **[docs/README.md](docs/README.md)** — it indexes every doc with a
"I want to…" intent table. Quick map:
| Area | Entry point |
|---|---|
| Plan & live module URLs | [docs/roadmap.md](docs/roadmap.md) |
| System architecture (services, Docker, SSO) | [docs/architecture/overview.md](docs/architecture/overview.md) |
| ERPNext data model + customer flows | [docs/architecture/data-model.md](docs/architecture/data-model.md) |
| Frontend patterns (Vue/Quasar/Pinia) | [docs/architecture/app-design.md](docs/architecture/app-design.md) |
| Billing, Stripe, invoices | [docs/features/billing-payments.md](docs/features/billing-payments.md) |
| CPE / modems / ONTs / TR-069 | [docs/features/cpe-management.md](docs/features/cpe-management.md) |
| Scanner / OCR / Gemini pipeline | [docs/features/vision-ocr.md](docs/features/vision-ocr.md) |
| Agent flows (Flow Template) | [docs/features/flow-editor.md](docs/features/flow-editor.md) |
| Wizard SKU vs legacy audit | [docs/reference/erpnext-item-diff.md](docs/reference/erpnext-item-diff.md) |
| Historical snapshots & migration logs | [docs/archive/](docs/archive/) |

75
apps/client/deploy.sh Executable file
View File

@ -0,0 +1,75 @@
#!/bin/bash
# ─────────────────────────────────────────────────────────────────────────────
# deploy.sh — Build Gigafibre Customer Portal and ship to portal.gigafibre.ca
#
# Topology (2026-04-22):
# portal.gigafibre.ca → standalone nginx:alpine container `client-portal`
# serving /opt/client-app/ on erp.gigafibre.ca.
# client.gigafibre.ca → Traefik 302 → portal.gigafibre.ca (legacy alias).
#
# We build the PWA with base=/ (the portal host serves from root, not from
# /assets/client-app/ like the old ERPNext-embedded deployment) and rsync
# the dist/pwa/ output into /opt/client-app/ on the server. The nginx
# container bind-mounts /opt/client-app/ read-only, so files appear live
# with no container restart.
#
# Usage:
# ./deploy.sh # build + ship to production (portal.gigafibre.ca)
# ./deploy.sh local # build only (dist/pwa/) — no deploy
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
SERVER="root@96.125.196.67"
SSH_KEY="$HOME/.ssh/proxmox_vm"
DEST="/opt/client-app"
echo "==> Installing dependencies..."
npm ci --silent
echo "==> Building PWA (base=/ for portal.gigafibre.ca)..."
# VITE_ERP_TOKEN is still needed by a few API calls that hit ERPNext
# directly (catalog, invoice PDFs). TODO: migrate these behind the hub.
VITE_ERP_TOKEN="b273a666c86d2d0:06120709db5e414" DEPLOY_BASE=/ npx quasar build -m pwa
if [ "${1:-}" = "local" ]; then
echo ""
echo "Local build done. Output: dist/pwa/"
exit 0
fi
echo "==> Packaging..."
tar czf /tmp/client-pwa.tar.gz -C dist/pwa .
echo "==> Shipping to $SERVER:$DEST ..."
# We deploy to a staging dir and flip atomically — this avoids serving
# a half-written index.html referencing new hashed assets that haven't
# finished uploading (would 404 the SPA for a second or two).
cat /tmp/client-pwa.tar.gz | ssh -i "$SSH_KEY" "$SERVER" "bash -s" <<'REMOTE'
set -euo pipefail
cat > /tmp/client.tar.gz
STAGE=$(mktemp -d /opt/client-app.new.XXXXXX)
tar xzf /tmp/client.tar.gz -C "$STAGE"
# Preserve docker-compose.yml + nginx.conf from live dir (they live alongside the SPA)
cp /opt/client-app/docker-compose.yml "$STAGE/" 2>/dev/null || true
cp /opt/client-app/nginx.conf "$STAGE/" 2>/dev/null || true
# Atomic flip
BACKUP=/opt/client-app.bak.$(date +%s)
mv /opt/client-app "$BACKUP"
mv "$STAGE" /opt/client-app
# Keep last 3 backups, prune the rest
ls -dt /opt/client-app.bak.* 2>/dev/null | tail -n +4 | xargs -r rm -rf
rm -f /tmp/client.tar.gz
# nginx bind-mount follows the original inode, so the `mv` swap above
# leaves the container pointed at the backup dir. Restart to re-bind.
docker restart client-portal >/dev/null
echo " Deployed. Backup: $BACKUP"
REMOTE
rm -f /tmp/client-pwa.tar.gz
echo ""
echo "Done! Customer Portal: https://portal.gigafibre.ca/"
echo "Legacy alias (302): https://client.gigafibre.ca/"

19
apps/client/index.html Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<title>Gigafibre</title>
<meta charset="utf-8">
<meta name="description" content="Portail client Gigafibre">
<meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="no">
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png">
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
<link rel="icon" type="image/ico" href="favicon.ico">
</head>
<body>
<!-- quasar:entry-point -->
</body>
</html>

9996
apps/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
apps/client/package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "gigafibre-client",
"version": "1.0.0",
"description": "Gigafibre Customer Portal",
"productName": "Gigafibre",
"private": true,
"scripts": {
"dev": "quasar dev",
"build": "quasar build",
"lint": "eslint --ext .js,.vue ./src"
},
"dependencies": {
"@quasar/extras": "^1.16.12",
"pinia": "^2.1.7",
"quasar": "^2.16.10",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@quasar/app-vite": "^1.10.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.24.0",
"sass": "^1.72.0",
"workbox-build": "7.0.x",
"workbox-cacheable-response": "7.0.x",
"workbox-core": "7.0.x",
"workbox-expiration": "7.0.x",
"workbox-precaching": "7.0.x",
"workbox-routing": "7.0.x",
"workbox-strategies": "7.0.x"
},
"engines": {
"node": "^20 || ^18",
"npm": ">= 6.13.4"
}
}

View File

@ -0,0 +1,3 @@
module.exports = {
plugins: [],
}

View File

@ -0,0 +1,263 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Gigafibre</title>
<link rel="icon" type="image/png" href="/icons/favicon-128x128.png">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; color: #1e293b; -webkit-font-smoothing: antialiased; }
.chat-page { display: flex; flex-direction: column; height: 100vh; height: 100dvh; max-width: 600px; margin: 0 auto; background: #f8fafc; position: relative; }
.chat-loading, .chat-error { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8; gap: 12px; }
.chat-error-icon { font-size: 48px; }
.spinner { width: 36px; height: 36px; border: 3px solid #e2e8f0; border-top-color: #4f46e5; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.chat-header { display: flex; align-items: center; gap: 12px; padding: 14px 16px; background: #fff; border-bottom: 1px solid #e2e8f0; }
.chat-logo { width: 36px; height: 36px; border-radius: 8px; background: #4f46e5; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 700; font-size: 14px; flex-shrink: 0; }
.chat-title { font-weight: 600; font-size: 1rem; }
.chat-sub { font-size: 0.8rem; color: #94a3b8; }
.chat-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
.chat-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8; }
.chat-msg { display: flex; }
.chat-msg-customer { justify-content: flex-end; }
.chat-msg-agent { justify-content: flex-start; }
.chat-msg-system { justify-content: center; }
.chat-bubble { max-width: 80%; padding: 10px 14px; border-radius: 16px; font-size: 0.92rem; line-height: 1.4; word-break: break-word; }
.chat-msg-customer .chat-bubble { background: #4f46e5; color: #fff; border-bottom-right-radius: 4px; }
.chat-msg-agent .chat-bubble { background: #fff; color: #1e293b; border: 1px solid #e2e8f0; border-bottom-left-radius: 4px; }
.chat-msg-system .chat-bubble { background: transparent; color: #94a3b8; font-size: 0.8rem; padding: 4px 8px; }
.chat-meta { font-size: 0.7rem; opacity: 0.6; margin-top: 4px; text-align: right; }
.chat-via { background: rgba(255,255,255,0.2); padding: 1px 4px; border-radius: 3px; font-size: 0.65rem; margin-right: 4px; }
.chat-msg-agent .chat-via { background: #f1f5f9; }
.chat-input-bar { display: flex; gap: 8px; padding: 10px 12px; background: #fff; border-top: 1px solid #e2e8f0; }
.chat-input { flex: 1; border: 1px solid #e2e8f0; border-radius: 20px; padding: 10px 16px; font-size: 0.92rem; outline: none; resize: none; font-family: inherit; min-height: 42px; max-height: 120px; }
.chat-input:focus { border-color: #4f46e5; }
.chat-send { width: 42px; height: 42px; border-radius: 50%; background: #4f46e5; border: none; color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: background 0.2s; }
.chat-send:hover { background: #4338ca; }
.chat-send:disabled { background: #cbd5e1; cursor: default; }
.chat-send svg { width: 20px; height: 20px; }
.chat-closed { padding: 16px; text-align: center; color: #94a3b8; font-size: 0.9rem; border-top: 1px solid #e2e8f0; }
.chat-push-banner { position: absolute; bottom: 70px; left: 12px; right: 12px; background: #4f46e5; color: #fff; padding: 10px 14px; border-radius: 10px; display: flex; align-items: center; font-size: 0.85rem; cursor: pointer; box-shadow: 0 4px 12px rgba(79,70,229,0.3); gap: 8px; }
.chat-push-banner .close { margin-left: auto; background: none; border: none; color: #fff; cursor: pointer; font-size: 18px; padding: 0 4px; }
</style>
</head>
<body>
<div id="app" class="chat-page">
<div class="chat-loading">
<div class="spinner"></div>
<div>Chargement...</div>
</div>
</div>
<script>
const HUB_URL = 'https://msg.gigafibre.ca'
const token = window.location.pathname.split('/c/')[1]?.split('/')[0]?.split('?')[0]
const app = document.getElementById('app')
let conv = null
let eventSource = null
if (!token) {
showError('Lien invalide')
} else {
loadConversation()
}
async function loadConversation () {
try {
const res = await fetch(`${HUB_URL}/conversations/${token}`)
if (!res.ok) { showError('Conversation introuvable'); return }
conv = await res.json()
render()
connectSSE()
checkPush()
} catch {
showError('Erreur de connexion')
}
}
function showError (msg) {
app.innerHTML = `<div class="chat-error"><div class="chat-error-icon"></div><div style="font-size:1.1rem;font-weight:600">${msg}</div><div style="font-size:0.85rem">Ce lien a peut-être expiré ou est invalide.</div></div>`
}
function render () {
const msgs = conv.messages.map(m => `
<div class="chat-msg chat-msg-${m.from}">
<div class="chat-bubble">
<div>${escapeHtml(m.text)}</div>
<div class="chat-meta">${m.via === 'sms' ? '<span class="chat-via">SMS</span>' : ''}${formatTime(m.ts)}</div>
</div>
</div>
`).join('')
const inputBar = conv.status === 'active'
? `<div class="chat-input-bar">
<textarea id="msgInput" class="chat-input" placeholder="Tapez votre message..." rows="1"></textarea>
<button id="sendBtn" class="chat-send" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/></svg>
</button>
</div>`
: '<div class="chat-closed">✓ Cette conversation est terminée.</div>'
app.innerHTML = `
<div class="chat-header">
<div class="chat-logo">G</div>
<div>
<div class="chat-title">${escapeHtml(conv.subject || 'Gigafibre')}</div>
<div class="chat-sub">${escapeHtml(conv.customerName || '')}</div>
</div>
</div>
<div id="messages" class="chat-messages">
${msgs || '<div class="chat-empty"><div style="font-size:32px;color:#cbd5e1">💬</div><div>Commencez la conversation</div></div>'}
</div>
${inputBar}
`
scrollToBottom()
// Bind events
const input = document.getElementById('msgInput')
const btn = document.getElementById('sendBtn')
if (input && btn) {
input.addEventListener('input', () => {
btn.disabled = !input.value.trim()
input.style.height = 'auto'
input.style.height = Math.min(input.scrollHeight, 120) + 'px'
})
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() }
})
btn.addEventListener('click', sendMessage)
}
}
async function sendMessage () {
const input = document.getElementById('msgInput')
const text = input?.value?.trim()
if (!text) return
input.value = ''
input.style.height = 'auto'
document.getElementById('sendBtn').disabled = true
try {
const res = await fetch(`${HUB_URL}/conversations/${token}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
})
const data = await res.json()
if (data.message && !conv.messages.find(m => m.id === data.message.id)) {
conv.messages.push(data.message)
appendMessage(data.message)
}
} catch (e) {
console.error('Send error:', e)
}
}
function appendMessage (msg) {
const container = document.getElementById('messages')
if (!container) return
// Remove empty state
const empty = container.querySelector('.chat-empty')
if (empty) empty.remove()
const div = document.createElement('div')
div.className = `chat-msg chat-msg-${msg.from}`
div.innerHTML = `
<div class="chat-bubble">
<div>${escapeHtml(msg.text)}</div>
<div class="chat-meta">${msg.via === 'sms' ? '<span class="chat-via">SMS</span>' : ''}${formatTime(msg.ts)}</div>
</div>
`
container.appendChild(div)
scrollToBottom()
}
function connectSSE () {
eventSource = new EventSource(`${HUB_URL}/conversations/${token}/sse`)
eventSource.addEventListener('conv-message', (e) => {
try {
const data = JSON.parse(e.data)
const msg = data.message
if (!conv.messages.find(m => m.id === msg.id)) {
conv.messages.push(msg)
appendMessage(msg)
}
} catch {}
})
}
function checkPush () {
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !conv.vapidPublicKey) return
if (Notification.permission === 'granted') { registerPush(); return }
if (Notification.permission === 'default') {
// Show banner after a short delay
setTimeout(() => {
const banner = document.createElement('div')
banner.className = 'chat-push-banner'
banner.innerHTML = '🔔 Activer les notifications pour ne pas manquer de réponse <button class="close"></button>'
banner.addEventListener('click', async (e) => {
if (e.target.classList.contains('close')) { banner.remove(); return }
const perm = await Notification.requestPermission()
banner.remove()
if (perm === 'granted') registerPush()
})
app.appendChild(banner)
}, 2000)
}
}
async function registerPush () {
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(conv.vapidPublicKey),
})
await fetch(`${HUB_URL}/conversations/${token}/push`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub.toJSON()),
})
} catch (e) { console.error('Push registration failed:', e) }
}
function urlBase64ToUint8Array (base64) {
const padding = '='.repeat((4 - base64.length % 4) % 4)
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(b64)
return Uint8Array.from([...raw].map(c => c.charCodeAt(0)))
}
function scrollToBottom () {
const el = document.getElementById('messages')
if (el) el.scrollTop = el.scrollHeight
}
function escapeHtml (str) {
if (!str) return ''
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function formatTime (ts) {
if (!ts) return ''
const d = new Date(ts)
const now = new Date()
const time = d.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })
if (d.toDateString() === now.toDateString()) return time
return d.toLocaleDateString('fr-CA', { month: 'short', day: 'numeric' }) + ' ' + time
}
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -0,0 +1,66 @@
/* eslint-env node */
const { configure } = require('quasar/wrappers')
module.exports = configure(function () {
return {
boot: ['pinia'],
css: ['app.scss'],
extras: ['roboto-font', 'material-icons'],
build: {
target: {
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
node: 'node20',
},
vueRouterMode: 'hash',
extendViteConf (viteConf) {
viteConf.base = process.env.DEPLOY_BASE || '/assets/client-app/'
},
},
devServer: {
open: false,
host: '0.0.0.0',
port: 9002,
proxy: {
'/api': {
target: 'https://erp.gigafibre.ca',
changeOrigin: true,
},
},
},
framework: {
config: {},
plugins: ['Notify', 'Loading', 'Dialog'],
},
animations: [],
pwa: {
workboxMode: 'generateSW',
injectPwaMetaTags: true,
swFilename: 'sw.js',
manifestFilename: 'manifest.json',
useCredentialForManifestTag: false,
workboxOptions: {
skipWaiting: true,
clientsClaim: true,
cleanupOutdatedCaches: true,
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api\//],
},
extendManifestJson (json) {
json.name = 'Gigafibre'
json.short_name = 'Gigafibre'
json.description = 'Portail client Gigafibre'
json.display = 'standalone'
json.background_color = '#ffffff'
json.theme_color = '#0ea5e9'
json.start_url = '.'
},
},
}
})

View File

@ -0,0 +1,30 @@
/* eslint-env serviceworker */
/*
* This file (which will be your service worker)
* is picked up by the build system ONLY if
* quasar.config.js > pwa > workboxMode is set to "injectManifest"
*/
import { clientsClaim } from 'workbox-core'
import { precacheAndRoute, cleanupOutdatedCaches, createHandlerBoundToURL } from 'workbox-precaching'
import { registerRoute, NavigationRoute } from 'workbox-routing'
self.skipWaiting()
clientsClaim()
// Use with precache injection
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
// Non-SSR fallback to index.html
// Production SSR fallback to offline.html (except for dev)
if (process.env.MODE !== 'ssr' || process.env.PROD) {
registerRoute(
new NavigationRoute(
createHandlerBoundToURL(process.env.PWA_FALLBACK_HTML),
{ denylist: [/sw\.js$/, /workbox-(.)*\.js$/] }
)
)
}

View File

@ -0,0 +1,32 @@
{
"orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#027be3",
"icons": [
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/icon-256x256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

10
apps/client/src-pwa/pwa-flag.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/* eslint-disable */
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
import "quasar/dist/types/feature-flag";
declare module "quasar/dist/types/feature-flag" {
interface QuasarFeatureFlags {
pwa: true;
}
}

View File

@ -0,0 +1,41 @@
import { register } from 'register-service-worker'
// The ready(), registered(), cached(), updatefound() and updated()
// events passes a ServiceWorkerRegistration instance in their arguments.
// ServiceWorkerRegistration: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration
register(process.env.SERVICE_WORKER_FILE, {
// The registrationOptions object will be passed as the second argument
// to ServiceWorkerContainer.register()
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register#Parameter
// registrationOptions: { scope: './' },
ready (/* registration */) {
// console.log('Service worker is active.')
},
registered (/* registration */) {
// console.log('Service worker has been registered.')
},
cached (/* registration */) {
// console.log('Content has been cached for offline use.')
},
updatefound (/* registration */) {
// console.log('New content is downloading.')
},
updated (/* registration */) {
// console.log('New content is available; please refresh.')
},
offline () {
// console.log('No internet connection found. App is running in offline mode.')
},
error (/* err */) {
// console.error('Error during service worker registration:', err)
}
})

16
apps/client/src/App.vue Normal file
View File

@ -0,0 +1,16 @@
<template>
<router-view />
</template>
<script setup>
import { onMounted } from 'vue'
import { useCustomerStore } from 'src/stores/customer'
const store = useCustomerStore()
onMounted(() => {
store.init().catch(() => {
// Auth not available (standalone portal mode) continue as guest
console.info('[Portal] Auth unavailable — running in guest mode')
})
})
</script>

View File

@ -0,0 +1,32 @@
/**
* Portal passwordless auth talks to targo-hub /portal/*.
*
* Flow:
* 1. Customer lands on /#/login (no token, no session).
* 2. Types email or phone, hits "Recevoir mon lien".
* 3. requestPortalLink() POSTs the identifier to the hub.
* 4. Hub looks up the Customer, mints a 24h JWT, sends via SMS + email.
* 5. Customer clicks the link in their inbox portal.gigafibre.ca/#/?token=JWT.
* 6. useMagicToken() decodes it on page load, hydrates the customer store.
*
* The hub always returns 200 OK (anti-enumeration), so the only
* non-success response the UI should handle is 429 (rate limit).
*/
const HUB = location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
export async function requestPortalLink (identifier) {
const r = await fetch(HUB + '/portal/request-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier }),
})
const data = await r.json().catch(() => ({}))
if (r.status === 429) {
const err = new Error(data.message || 'Trop de tentatives. Réessayez plus tard.')
err.code = 'rate_limit'
err.retryAfterSec = data.retry_after_sec || 900
throw err
}
if (!r.ok) throw new Error(data.error || `Hub ${r.status}`)
return data
}

View File

@ -0,0 +1,63 @@
import { BASE_URL } from 'src/config/erpnext'
/**
* Auth shim for the customer portal.
*
* Before 2026-04-22 this wrapped a staff Authentik session. Customers no
* longer authenticate via Authentik they arrive with a magic-link JWT
* and the customer store is hydrated directly from it. See stores/customer.js.
*
* We still read an ERP service token so ERPNext's REST resources (Sales
* Invoice, Issue, ) accept our server-side calls. That token is a fixed
* API key, not a session it's injected at build time via VITE_ERP_TOKEN
* or at runtime via window.__ERP_TOKEN__.
*/
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || window.__ERP_TOKEN__ || ''
export function authFetch (url, opts = {}) {
opts.headers = {
...opts.headers,
Authorization: 'token ' + SERVICE_TOKEN,
}
opts.redirect = 'manual'
if (opts.method && opts.method !== 'GET') {
opts.credentials = 'omit'
}
return fetch(url, opts).then(res => {
// 401 from ERPNext means the service token is broken — fail loud in console,
// but don't nuke the user's tab. Their magic-link session is independent.
if (res.status === 401) {
console.warn('[portal] ERPNext returned 401 — check VITE_ERP_TOKEN')
}
return res
})
}
export async function getLoggedUser () {
try {
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', {
headers: { Authorization: 'token ' + SERVICE_TOKEN },
})
if (res.ok) {
const data = await res.json()
return data.message || 'authenticated'
}
} catch {}
return 'authenticated'
}
/**
* Customer logout clear the store and bounce to /login. No Authentik
* involvement. The magic-link JWT, once minted, is stateless on the server
* and expires on its own; clearing the client store is sufficient.
*/
export async function logout () {
try {
const { useCustomerStore } = await import('src/stores/customer')
useCustomerStore().clear()
} catch { /* ignore — fall through to hard redirect */ }
window.location.hash = '#/login'
// Force a reload so every module drops any cached customer data.
window.location.reload()
}

View File

@ -0,0 +1,11 @@
const HUB = location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
const get = async (path) => { const r = await fetch(HUB + path); if (!r.ok) throw new Error(`Hub ${r.status}`); return r.json() }
const post = async (path, body) => { const r = await fetch(HUB + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!r.ok) throw new Error(`Hub ${r.status}`); return r.json() }
export const fetchCatalog = () => get('/api/catalog')
export const submitOrder = (payload) => post('/api/checkout', payload)
export const getOrderStatus = (id) => get(`/api/order/${encodeURIComponent(id)}`)
export const searchAddresses = async (q, limit = 8) => (await post('/api/address-search', { q, limit })).results || []
export const sendOTP = (identifier) => post('/api/otp/send', { identifier })
export const verifyOTP = (identifier, code) => post('/api/otp/verify', { identifier, code })

View File

@ -0,0 +1,49 @@
/**
* Payment API talks to targo-hub /payments/* endpoints
*/
const HUB = location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
async function hubGet (path) {
const r = await fetch(HUB + path)
if (!r.ok) {
const data = await r.json().catch(() => ({}))
throw new Error(data.error || `Hub ${r.status}`)
}
return r.json()
}
async function hubPost (path, body) {
const r = await fetch(HUB + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const data = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(data.error || `Hub ${r.status}`)
return data
}
/** Get customer balance + unpaid invoices */
export const getBalance = (customer) => hubGet(`/payments/balance/${encodeURIComponent(customer)}`)
/** Get saved payment methods for customer */
export const getPaymentMethods = (customer) => hubGet(`/payments/methods/${encodeURIComponent(customer)}`)
/** Get invoice payment info */
export const getInvoicePaymentInfo = (invoice) => hubGet(`/payments/invoice/${encodeURIComponent(invoice)}`)
/** Create checkout session for full balance */
export const checkoutBalance = (customer) => hubPost('/payments/checkout', { customer })
/** Create checkout session for specific invoice */
export const checkoutInvoice = (customer, invoice, { save_card = true, payment_method = 'card' } = {}) =>
hubPost('/payments/checkout-invoice', { customer, invoice, save_card, payment_method })
/** Save card (setup mode) */
export const setupCard = (customer) => hubPost('/payments/setup', { customer })
/** Open Stripe billing portal */
export const openBillingPortal = (customer) => hubPost('/payments/portal', { customer })
/** Toggle auto-pay (PPA) */
export const togglePPA = (customer, enabled) => hubPost('/payments/toggle-ppa', { customer, enabled })

View File

@ -0,0 +1,309 @@
import { authFetch } from './auth'
import { BASE_URL } from 'src/config/erpnext'
async function apiGet (path) {
const res = await authFetch(BASE_URL + path)
if (!res.ok) throw new Error(`API ${res.status}: ${path}`)
const ct = (res.headers.get('content-type') || '')
if (!ct.includes('application/json')) throw new Error('Not JSON response for: ' + path)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data
}
/**
* Get current portal user info from Authentik headers.
* Returns { email, customer_id, customer_name }
*/
export async function getPortalUser () {
const data = await apiGet('/api/method/client_portal_get_user_info')
return data.message
}
/**
* Fetch paginated Sales Invoices for a customer.
*/
export async function fetchInvoices (customer, { page = 1, pageSize = 20 } = {}) {
const start = (page - 1) * pageSize
const filters = JSON.stringify([
['customer', '=', customer],
['docstatus', '=', 1],
])
const fields = JSON.stringify([
'name', 'posting_date', 'due_date', 'grand_total',
'outstanding_amount', 'status', 'currency',
])
const path = `/api/resource/Sales Invoice?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&order_by=posting_date desc&limit_page_length=${pageSize}&limit_start=${start}`
const data = await apiGet(path)
return data.data || []
}
/**
* Count total invoices for pagination.
*/
export async function countInvoices (customer) {
const filters = JSON.stringify([
['customer', '=', customer],
['docstatus', '=', 1],
])
const data = await apiGet(`/api/method/frappe.client.get_count?doctype=Sales Invoice&filters=${encodeURIComponent(filters)}`)
return data.message || 0
}
/**
* Download invoice PDF.
*/
export async function fetchInvoicePDF (invoiceName, format = 'Facture TARGO') {
const url = `${BASE_URL}/api/method/frappe.utils.print_format.download_pdf?doctype=Sales%20Invoice&name=${encodeURIComponent(invoiceName)}&format=${encodeURIComponent(format)}`
const res = await authFetch(url)
if (!res.ok) throw new Error('PDF download failed')
return res.blob()
}
/**
* Fetch customer's Issues/Tickets.
*/
export async function fetchTickets (customer) {
const filters = JSON.stringify([['customer', '=', customer]])
const fields = JSON.stringify([
'name', 'subject', 'status', 'priority', 'creation',
'issue_type', 'sla_resolution_date',
])
const path = `/api/resource/Issue?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&order_by=creation desc&limit_page_length=50`
const data = await apiGet(path)
return data.data || []
}
/**
* Create a new support ticket.
*/
export async function createTicket (customer, subject, description) {
const res = await authFetch(BASE_URL + '/api/resource/Issue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer,
subject,
description,
issue_type: 'Support',
priority: 'Medium',
}),
})
if (!res.ok) throw new Error('Failed to create ticket')
const data = await res.json()
return data.data
}
/**
* Fetch customer profile with addresses.
*/
export async function fetchProfile (customer) {
const data = await apiGet(`/api/resource/Customer/${encodeURIComponent(customer)}`)
return data.data
}
/**
* Fetch addresses linked to customer.
*/
export async function fetchAddresses (customer) {
const filters = JSON.stringify([
['Dynamic Link', 'link_doctype', '=', 'Customer'],
['Dynamic Link', 'link_name', '=', customer],
])
const fields = JSON.stringify([
'name', 'address_title', 'address_line1', 'address_line2',
'city', 'state', 'pincode', 'country', 'is_primary_address',
])
const path = `/api/resource/Address?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}`
const data = await apiGet(path)
return data.data || []
}
/**
* Fetch Service Locations with their active Service Subscriptions.
* Returns locations grouped with subscriptions and monthly totals.
*/
export async function fetchServiceLocations (customer) {
// Fetch locations
const locFields = JSON.stringify([
'name', 'location_name', 'address_line', 'city', 'postal_code',
'province', 'status', 'connection_type',
])
const locFilters = JSON.stringify([['customer', '=', customer]])
const locPath = `/api/resource/Service Location?filters=${encodeURIComponent(locFilters)}&fields=${encodeURIComponent(locFields)}&order_by=creation asc&limit_page_length=50`
const locData = await apiGet(locPath)
const locations = locData.data || []
// Fetch subscriptions
const subFields = JSON.stringify([
'name', 'service_location', 'status', 'service_category',
'plan_name', 'monthly_price', 'speed_down', 'speed_up',
'billing_cycle', 'start_date', 'end_date', 'promo_end',
])
const subFilters = JSON.stringify([
['customer', '=', customer],
['status', '=', 'Actif'],
])
const subPath = `/api/resource/Service Subscription?filters=${encodeURIComponent(subFilters)}&fields=${encodeURIComponent(subFields)}&order_by=service_category asc, monthly_price desc&limit_page_length=200`
const subData = await apiGet(subPath)
const subscriptions = subData.data || []
// Group subscriptions by location
const subsByLoc = {}
for (const sub of subscriptions) {
const loc = sub.service_location || '_unassigned'
if (!subsByLoc[loc]) subsByLoc[loc] = []
subsByLoc[loc].push(sub)
}
// Attach subscriptions to locations and compute totals
for (const loc of locations) {
loc.subscriptions = subsByLoc[loc.name] || []
loc.monthly_total = loc.subscriptions.reduce((sum, s) => sum + (s.monthly_price || 0), 0)
}
// Grand total across all locations
const grandTotal = locations.reduce((sum, loc) => sum + loc.monthly_total, 0)
return { locations, grandTotal, subscriptionCount: subscriptions.length }
}
/**
* Fetch a single Sales Invoice with line items.
*/
export async function fetchInvoice (invoiceName) {
const data = await apiGet(`/api/resource/Sales Invoice/${encodeURIComponent(invoiceName)}`)
return data.data
}
/**
* Fetch invoice HTML print preview (Jinja rendered).
*/
export async function fetchInvoiceHTML (invoiceName, format = 'Facture TARGO') {
const url = `${BASE_URL}/api/method/frappe.www.printview.get_html_and_style?doc=Sales Invoice&name=${encodeURIComponent(invoiceName)}&print_format=${encodeURIComponent(format)}&no_letterhead=0`
const res = await authFetch(url)
if (!res.ok) throw new Error('Print preview failed')
const data = await res.json()
return data.message || {}
}
/**
* Fetch a single Issue with full details.
*/
export async function fetchTicket (ticketName) {
const data = await apiGet(`/api/resource/Issue/${encodeURIComponent(ticketName)}`)
return data.data
}
/**
* Fetch Communications linked to a reference (Issue or Customer).
*/
export async function fetchCommunications (refDoctype, refName, limit = 100) {
const filters = JSON.stringify([
['reference_doctype', '=', refDoctype],
['reference_name', '=', refName],
])
const fields = JSON.stringify([
'name', 'communication_medium', 'communication_type',
'sent_or_received', 'sender', 'sender_full_name', 'phone_no',
'subject', 'content', 'creation', 'status',
'reference_doctype', 'reference_name',
])
const path = `/api/resource/Communication?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&order_by=creation asc&limit_page_length=${limit}`
const data = await apiGet(path)
return data.data || []
}
/**
* Fetch all Communications for a customer (across all references + direct).
* Returns Communications where reference is Customer or Issue belonging to customer.
*/
export async function fetchAllCommunications (customer, limit = 200) {
// Direct communications on customer
const directFilters = JSON.stringify([
['reference_doctype', '=', 'Customer'],
['reference_name', '=', customer],
])
const fields = JSON.stringify([
'name', 'communication_medium', 'communication_type',
'sent_or_received', 'sender', 'sender_full_name', 'phone_no',
'subject', 'content', 'creation', 'status',
'reference_doctype', 'reference_name',
])
const directPath = `/api/resource/Communication?filters=${encodeURIComponent(directFilters)}&fields=${encodeURIComponent(fields)}&order_by=creation desc&limit_page_length=${limit}`
const directData = await apiGet(directPath)
// Communications on customer's Issues
const issueFilters = JSON.stringify([
['reference_doctype', '=', 'Issue'],
['reference_name', 'in', []], // will be filled after ticket fetch
])
// First get the customer's ticket names
const ticketFilters = JSON.stringify([['customer', '=', customer]])
const ticketFields = JSON.stringify(['name'])
const ticketPath = `/api/resource/Issue?filters=${encodeURIComponent(ticketFilters)}&fields=${encodeURIComponent(ticketFields)}&limit_page_length=200`
const ticketData = await apiGet(ticketPath)
const ticketNames = (ticketData.data || []).map(t => t.name)
let issueComs = []
if (ticketNames.length) {
const issueComsFilters = JSON.stringify([
['reference_doctype', '=', 'Issue'],
['reference_name', 'in', ticketNames],
])
const issueComsPath = `/api/resource/Communication?filters=${encodeURIComponent(issueComsFilters)}&fields=${encodeURIComponent(fields)}&order_by=creation desc&limit_page_length=${limit}`
const issueComsData = await apiGet(issueComsPath)
issueComs = issueComsData.data || []
}
// Merge, deduplicate, sort desc
const all = [...(directData.data || []), ...issueComs]
const seen = new Set()
const unique = all.filter(c => {
if (seen.has(c.name)) return false
seen.add(c.name)
return true
})
unique.sort((a, b) => new Date(b.creation) - new Date(a.creation))
return unique
}
/**
* Post a reply on a ticket (creates a Communication).
*/
export async function replyToTicket (ticketName, message) {
const res = await authFetch(BASE_URL + '/api/resource/Communication', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
communication_type: 'Communication',
communication_medium: 'Other',
sent_or_received: 'Sent',
sender: 'portal@gigafibre.ca',
content: message,
reference_doctype: 'Issue',
reference_name: ticketName,
status: 'Linked',
}),
})
if (!res.ok) throw new Error('Failed to send reply')
const data = await res.json()
return data.data
}
/**
* Fetch Comments linked to a document (visible to portal users).
*/
export async function fetchComments (refDoctype, refName) {
const filters = JSON.stringify([
['reference_doctype', '=', refDoctype],
['reference_name', '=', refName],
['comment_type', '=', 'Comment'],
])
const fields = JSON.stringify([
'name', 'comment_by', 'content', 'creation',
])
const path = `/api/resource/Comment?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&order_by=creation asc&limit_page_length=100`
const data = await apiGet(path)
return data.data || []
}

View File

@ -0,0 +1,6 @@
import { boot } from 'quasar/wrappers'
import { createPinia } from 'pinia'
export default boot(({ app }) => {
app.use(createPinia())
})

View File

@ -0,0 +1,49 @@
import { ref, onUnmounted } from 'vue'
import { searchAddresses } from 'src/api/catalog'
export function useAddressSearch (form) {
const query = ref('')
const results = ref([])
const searching = ref(false)
const selected = ref(false)
let debounce = null
function onInput (val) {
selected.value = false
Object.assign(form, { address: '', city: '', postalCode: '', latitude: null, longitude: null })
if (debounce) clearTimeout(debounce)
if (!val || val.length < 3) { results.value = []; return }
debounce = setTimeout(async () => {
searching.value = true
try { results.value = await searchAddresses(val) }
catch { results.value = [] }
finally { searching.value = false }
}, 300)
}
function selectResult (a) {
Object.assign(form, {
address: a.adresse_formatee || '', city: a.nom_municipalite || '',
postalCode: a.code_postal || '', latitude: a.latitude || null,
longitude: a.longitude || null, province: 'QC',
})
query.value = a.adresse_formatee || ''
results.value = []
selected.value = true
}
function selectCustomerAddr (addr) {
Object.assign(form, {
address: addr.address || '', city: addr.city || '',
postalCode: addr.postal_code || '', latitude: addr.latitude || null,
longitude: addr.longitude || null, province: 'QC',
})
query.value = addr.address || ''
selected.value = true
results.value = []
}
onUnmounted(() => { if (debounce) clearTimeout(debounce) })
return { query, results, searching, selected, onInput, selectResult, selectCustomerAddr }
}

View File

@ -0,0 +1,24 @@
export function useFormatters () {
function formatDate (d) {
if (!d) return ''
return new Date(d).toLocaleDateString('fr-CA', {
year: 'numeric', month: 'long', day: 'numeric',
})
}
function formatShortDate (d) {
if (!d) return ''
return new Date(d).toLocaleDateString('fr-CA', {
year: 'numeric', month: '2-digit', day: '2-digit',
})
}
function formatMoney (v) {
if (v == null) return ''
return Number(v).toLocaleString('fr-CA', {
style: 'currency', currency: 'CAD',
})
}
return { formatDate, formatShortDate, formatMoney }
}

View File

@ -0,0 +1,31 @@
/**
* Magic link helper for payment-return pages.
*
* The customer store now owns all token decoding (see stores/customer.js:
* `hydrateFromToken`). This composable is a thin read-through so the
* payment pages can render `v-if="auth.authenticated"` without caring
* about where the session came from (magic-link URL vs. existing nav).
*
* The router guard already calls `store.hydrateFromToken()` before these
* pages mount, so by the time this runs the store is either hydrated or
* still empty no extra decoding needed here.
*/
import { useRoute } from 'vue-router'
import { useCustomerStore } from 'src/stores/customer'
export function useMagicToken () {
const route = useRoute()
const store = useCustomerStore()
// `expired` is a hint for the UI: "you arrived here with a token but we
// couldn't use it". Approximated by: URL had a token (route.query or the
// raw hash) but the store is still empty.
const urlHadToken =
!!route.query.token ||
(typeof window !== 'undefined' && window.location.hash.includes('token='))
const authenticated = !!store.customerId
const expired = urlHadToken && !authenticated
return { authenticated, expired, customerId: store.customerId }
}

View File

@ -0,0 +1,72 @@
import { ref, nextTick } from 'vue'
import { sendOTP as apiSendOTP, verifyOTP as apiVerifyOTP } from 'src/api/catalog'
import { normalizePhone } from 'src/utils/format'
export function useOTP (form, { onVerified } = {}) {
const inputRef = ref(null)
const identifier = ref('')
const step = ref('idle') // idle | verify
const code = ref('')
const sending = ref(false)
const verifying = ref(false)
const verified = ref(false)
const channel = ref('')
const error = ref('')
const addresses = ref([])
async function send () {
sending.value = true
error.value = ''
try {
const result = await apiSendOTP(identifier.value)
if (!result.found) {
error.value = 'Aucun compte trouvé avec cet identifiant.'
return false
}
step.value = 'verify'
channel.value = result.channel
nextTick(() => inputRef.value?.focus())
return true
} catch (e) {
error.value = e.message || 'Erreur'
return false
} finally { sending.value = false }
}
async function verify () {
verifying.value = true
error.value = ''
try {
const result = await apiVerifyOTP(identifier.value, code.value)
if (!result.valid) {
error.value = result.reason === 'expired' ? 'Code expiré. Renvoyez un nouveau code.'
: result.reason === 'wrong_code' ? 'Code invalide. Vérifiez et réessayez.'
: 'Vérification échouée.'
return false
}
verified.value = true
form.customer_id = result.customer_id
form.name = result.customer_name || form.name
if (result.phone) form.phone = normalizePhone(result.phone)
if (result.email) form.email = result.email
else if (identifier.value.includes('@')) form.email = identifier.value
if (result.addresses?.length) addresses.value = result.addresses
onVerified?.(result)
return true
} catch (e) {
error.value = e.message || 'Erreur'
return false
} finally { verifying.value = false }
}
function reset () {
step.value = 'idle'
code.value = ''
verified.value = false
error.value = ''
addresses.value = []
form.customer_id = null
}
return { inputRef, identifier, step, code, sending, verifying, verified, channel, error, addresses, send, verify, reset }
}

View File

@ -0,0 +1,62 @@
import { ref, onUnmounted } from 'vue'
/**
* Smart incremental polling composable.
* Calls `fetchFn()` at `interval` ms. Compares result count/IDs
* with previous to detect new items and trigger `onNew` callback.
*
* @param {Function} fetchFn - async function that returns array of items
* @param {Object} opts
* @param {number} opts.interval - poll interval in ms (default 10000)
* @param {Function} opts.getId - function to get unique ID from item (default: item => item.name)
* @param {Function} opts.onNew - callback when new items detected, receives array of new items
*/
export function usePolling (fetchFn, opts = {}) {
const interval = opts.interval || 10000
const getId = opts.getId || (item => item.name)
const onNew = opts.onNew || (() => {})
const knownIds = ref(new Set())
let timer = null
let running = false
function seedKnownIds (items) {
knownIds.value = new Set(items.map(getId))
}
async function poll () {
if (running) return
running = true
try {
const items = await fetchFn()
const newItems = items.filter(item => !knownIds.value.has(getId(item)))
if (newItems.length) {
for (const item of items) {
knownIds.value.add(getId(item))
}
onNew(newItems, items)
}
} catch {
// Silent fail on poll
} finally {
running = false
}
}
function start () {
stop()
timer = setInterval(poll, interval)
}
function stop () {
if (timer) {
clearInterval(timer)
timer = null
}
}
// Auto-cleanup on component unmount
onUnmounted(stop)
return { start, stop, poll, seedKnownIds }
}

View File

@ -0,0 +1,65 @@
import { ref, onUnmounted } from 'vue'
const HUB_URL = 'https://msg.gigafibre.ca'
/**
* SSE composable for real-time Communication events from targo-hub.
*/
export function useSSE (opts = {}) {
const connected = ref(false)
let es = null
let reconnectTimer = null
let reconnectDelay = 1000
function connect (topics) {
disconnect()
if (!topics || !topics.length) return
const url = `${HUB_URL}/sse?topics=${encodeURIComponent(topics.join(','))}`
es = new EventSource(url)
es.onopen = () => {
connected.value = true
reconnectDelay = 1000
}
es.addEventListener('message', (e) => {
try {
const data = JSON.parse(e.data)
if (opts.onMessage) opts.onMessage(data)
} catch {}
})
es.addEventListener('sms-incoming', (e) => {
try {
const data = JSON.parse(e.data)
if (opts.onSmsIncoming) opts.onSmsIncoming(data)
} catch {}
})
es.onerror = () => {
connected.value = false
if (es && es.readyState === EventSource.CLOSED) {
scheduleReconnect(topics)
}
}
}
function scheduleReconnect (topics) {
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, 30000)
connect(topics)
}, reconnectDelay)
}
function disconnect () {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null }
if (es) { es.close(); es = null }
connected.value = false
}
onUnmounted(disconnect)
return { connect, disconnect, connected }
}

View File

@ -0,0 +1 @@
export const BASE_URL = ''

View File

@ -0,0 +1,103 @@
// Gigafibre Client Portal Consumer branding
:root {
--gf-primary: #0ea5e9; // sky-500
--gf-primary-dark: #0284c7; // sky-600
--gf-accent: #06b6d4; // cyan-500
--gf-bg: #f8fafc; // slate-50
--gf-surface: #ffffff;
--gf-text: #1e293b; // slate-800
--gf-text-secondary: #64748b; // slate-500
--gf-border: #e2e8f0; // slate-200
--gf-success: #22c55e;
--gf-warning: #f59e0b;
--gf-danger: #ef4444;
}
body {
background: var(--gf-bg);
color: var(--gf-text);
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, sans-serif;
}
.q-drawer {
background: var(--gf-surface) !important;
border-right: 1px solid var(--gf-border) !important;
}
.portal-card {
background: var(--gf-surface);
border: 1px solid var(--gf-border);
border-radius: 12px;
padding: 20px;
}
.portal-header {
background: linear-gradient(135deg, var(--gf-primary), var(--gf-accent));
}
// Status chips
.status-paid, .status-closed, .status-resolved {
color: var(--gf-success);
font-weight: 600;
}
.status-unpaid, .status-overdue {
color: var(--gf-danger);
font-weight: 600;
}
.status-open {
color: var(--gf-primary);
font-weight: 600;
}
// Summary card number
.summary-value {
font-size: 2rem;
font-weight: 700;
color: var(--gf-primary);
}
.page-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--gf-text);
margin-bottom: 16px;
}
// Monthly total banner (service locations)
.monthly-total-banner {
background: linear-gradient(135deg, #f0f9ff, #e0f2fe);
border: 1px solid #bae6fd;
border-radius: 10px;
padding: 14px 18px;
}
// Location cards
.location-card {
background: var(--gf-bg);
border: 1px solid var(--gf-border);
border-radius: 10px;
padding: 14px;
}
.subscription-row {
padding: 6px 0;
border-bottom: 1px dashed var(--gf-border);
&:last-child { border-bottom: none; }
}
.location-subtotal {
padding-top: 6px;
border-top: 1px solid var(--gf-border);
}
// Clickable table rows
.clickable-table {
.q-table tbody tr {
cursor: pointer;
transition: background 0.15s;
&:hover {
background: rgba(14, 165, 233, 0.05);
}
}
}

View File

@ -0,0 +1,22 @@
// ISP product catalog — extracted from CatalogPage to reduce component size
export const CATALOG = [
{ item_code: 'INT-100', item_name: 'Internet 100 Mbps', rate: 49.99, billing_type: 'Mensuel', service_category: 'Internet', requires_visit: true, project_template_id: 'fiber_install', description: 'Parfait pour le web et streaming HD', speed_down: 100, speed_up: 30 },
{ item_code: 'INT-300', item_name: 'Internet 300 Mbps', rate: 69.99, billing_type: 'Mensuel', service_category: 'Internet', requires_visit: true, project_template_id: 'fiber_install', description: 'Idéal pour familles et télétravail', speed_down: 300, speed_up: 100, popular: true },
{ item_code: 'INT-500', item_name: 'Internet 500 Mbps', rate: 89.99, billing_type: 'Mensuel', service_category: 'Internet', requires_visit: true, project_template_id: 'fiber_install', description: 'Pour les gros téléchargements et le gaming', speed_down: 500, speed_up: 200 },
{ item_code: 'INT-1000', item_name: 'Internet 1 Gbps', rate: 109.99, billing_type: 'Mensuel', service_category: 'Internet', requires_visit: true, project_template_id: 'fiber_install', description: 'La vitesse maximale pour les professionnels', speed_down: 1000, speed_up: 500 },
{ item_code: 'TEL-BASE', item_name: 'Téléphonie résidentielle', rate: 19.99, billing_type: 'Mensuel', service_category: 'Téléphonie', requires_visit: false, description: 'Appels illimités au Canada + afficheur' },
{ item_code: 'TEL-INTL', item_name: 'Téléphonie internationale', rate: 29.99, billing_type: 'Mensuel', service_category: 'Téléphonie', requires_visit: false, description: 'Appels illimités Canada + 60 destinations internationales' },
{ item_code: 'BDL-300-TEL', item_name: 'Bundle Internet 300 + Téléphonie', rate: 79.99, billing_type: 'Mensuel', service_category: 'Bundle', requires_visit: true, project_template_id: 'fiber_install', description: 'Internet rapide et téléphonie à prix réduit', speed_down: 300, speed_up: 100, bundle_includes: ['Internet 300 Mbps', 'Téléphonie résidentielle'] },
{ item_code: 'BDL-500-TEL-IPTV', item_name: 'Bundle Internet 500 + Téléphonie + IPTV', rate: 109.99, billing_type: 'Mensuel', service_category: 'Bundle', requires_visit: true, project_template_id: 'fiber_install', description: 'Le forfait complet pour toute la famille', speed_down: 500, speed_up: 200, popular: true, bundle_includes: ['Internet 500 Mbps', 'Téléphonie résidentielle', 'IPTV 120+ chaînes'] },
{ item_code: 'EQ-ROUTER-WIFI6', item_name: 'Routeur Wi-Fi 6', rate: 149.99, billing_type: 'Unique', service_category: 'Équipement', requires_visit: false, description: 'Routeur haute performance, couverture optimale' },
{ item_code: 'EQ-MESH-NODE', item_name: 'Noeud Wi-Fi Mesh', rate: 99.99, billing_type: 'Unique', service_category: 'Équipement', requires_visit: false, description: 'Étend la couverture Wi-Fi dans les grandes maisons' },
{ item_code: 'EQ-ONT', item_name: 'Terminal fibre optique (ONT)', rate: 0, billing_type: 'Unique', service_category: 'Équipement', requires_visit: true, project_template_id: 'fiber_install', description: 'Inclus avec tout abonnement Internet fibre' },
{ item_code: 'SVC-TEST', item_name: 'Frais de test', rate: 1, billing_type: 'Unique', service_category: 'Équipement', requires_visit: false, description: 'Produit de test — 1$ frais unique' },
]
export const CATEGORY_COLORS = { Internet: 'indigo', 'Téléphonie': 'teal', Bundle: 'purple', 'Équipement': 'blue-grey' }
export const TIME_SLOTS = [
{ label: 'Matin (8h - 12h)', value: 'AM' },
{ label: 'Après-midi (12h - 17h)', value: 'PM' },
]

View File

@ -0,0 +1,86 @@
<template>
<q-layout view="lHh Lpr lFf">
<q-header class="portal-header" elevated>
<q-toolbar>
<q-btn flat dense round icon="menu" @click="drawer = !drawer" class="lt-md" />
<q-toolbar-title class="text-weight-bold">
Gigafibre
</q-toolbar-title>
<q-space />
<span v-if="store.customerName" class="text-body2 q-mr-md gt-sm">
{{ store.customerName }}
</span>
<q-btn flat round icon="shopping_cart" @click="$router.push('/panier')" title="Panier" class="q-mr-xs">
<q-badge v-if="cartStore.itemCount > 0" floating color="orange" :label="cartStore.itemCount" />
</q-btn>
<q-btn flat round icon="logout" @click="doLogout" title="Déconnexion" />
</q-toolbar>
</q-header>
<q-drawer v-model="drawer" :width="240" :breakpoint="1024" bordered>
<q-list padding>
<q-item-label header class="text-weight-bold q-pb-sm">
Mon portail
</q-item-label>
<q-item v-for="link in navLinks" :key="link.to"
clickable v-ripple :to="link.to" active-class="text-primary bg-blue-1">
<q-item-section avatar>
<q-icon :name="link.icon" />
</q-item-section>
<q-item-section>{{ link.label }}</q-item-section>
</q-item>
</q-list>
</q-drawer>
<q-page-container>
<!-- Loading state (only block if we expect auth to succeed) -->
<div v-if="store.loading && !guestMode" class="flex flex-center" style="min-height: 60vh">
<q-spinner-dots size="48px" color="primary" />
</div>
<!-- Error state only block for auth-required pages, let catalog/cart through -->
<div v-else-if="store.error && requiresAuth" class="flex flex-center" style="min-height: 60vh">
<div class="text-center">
<q-icon name="error_outline" size="64px" color="negative" />
<div class="text-h6 q-mt-md">{{ store.error }}</div>
<q-btn class="q-mt-lg" color="primary" label="Réessayer" @click="store.init()" />
</div>
</div>
<!-- Content -->
<router-view v-else />
</q-page-container>
</q-layout>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useCustomerStore } from 'src/stores/customer'
import { useCartStore } from 'src/stores/cart'
import { logout } from 'src/api/auth'
const route = useRoute()
const store = useCustomerStore()
const cartStore = useCartStore()
const drawer = ref(true)
// Pages that work without authentication
const publicRoutes = ['catalog', 'cart', 'order-success', 'payment-success', 'payment-cancel', 'payment-card-added']
const guestMode = computed(() => store.error && !store.customerId)
const requiresAuth = computed(() => !publicRoutes.includes(route.name))
const navLinks = [
{ to: '/', icon: 'dashboard', label: 'Tableau de bord' },
{ to: '/invoices', icon: 'receipt_long', label: 'Factures' },
{ to: '/tickets', icon: 'support_agent', label: 'Support' },
{ to: '/messages', icon: 'chat', label: 'Messages' },
{ to: '/me', icon: 'person', label: 'Mon compte' },
{ to: '/catalogue', icon: 'storefront', label: 'Catalogue' },
]
function doLogout () {
logout()
}
</script>

View File

@ -0,0 +1,305 @@
<template>
<q-page padding>
<div class="page-title">Mon compte</div>
<div class="row q-col-gutter-md">
<!-- Customer info -->
<div class="col-12 col-md-6">
<div class="portal-card">
<div class="text-subtitle1 text-weight-medium q-mb-md">Informations</div>
<div class="q-gutter-sm">
<div><strong>Nom:</strong> {{ store.customerName }}</div>
<div><strong>Courriel:</strong> {{ store.email }}</div>
<div><strong>No. client:</strong> {{ store.customerId }}</div>
<div v-if="profile"><strong>Langue:</strong> {{ profile.language || 'fr' }}</div>
</div>
</div>
</div>
<!-- Services & Locations -->
<div class="col-12 col-md-6">
<div class="portal-card">
<div class="text-subtitle1 text-weight-medium q-mb-md">Adresses de service</div>
<!-- Monthly total banner (shown when multiple locations) -->
<div v-if="serviceLocations.length > 1 && grandMonthlyTotal !== null" class="monthly-total-banner q-mb-md">
<div class="row items-center justify-between">
<div>
<div class="text-caption text-grey-7">Total mensuel estimé</div>
<div class="text-h5 text-weight-bold" :class="grandMonthlyTotal >= 0 ? 'text-primary' : 'text-positive'">
{{ formatMoney(grandMonthlyTotal) }}<span class="text-caption text-grey-6"> /mois</span>
</div>
</div>
<div class="text-caption text-grey-6">
{{ serviceLocations.length }} adresses · {{ subscriptionCount }} services
</div>
</div>
</div>
<div v-if="loadingServices" class="q-pa-md text-center">
<q-spinner-dots size="28px" color="primary" />
</div>
<div v-else-if="!serviceLocations.length" class="text-grey-6">Aucune adresse enregistrée</div>
<!-- Location cards -->
<div v-else class="q-gutter-sm">
<div v-for="loc in serviceLocations" :key="loc.name" class="location-card">
<!-- Location header -->
<div class="row items-center q-mb-xs">
<q-icon name="place" size="18px" color="primary" class="q-mr-xs" />
<div class="text-weight-medium">{{ loc.location_name || loc.address_line }}</div>
<q-space />
<q-badge v-if="loc.connection_type" outline color="grey-7" :label="loc.connection_type" class="q-mr-xs" />
<q-badge :color="loc.status === 'Active' ? 'positive' : 'grey'" :label="loc.status" />
</div>
<div class="text-caption text-grey-6 q-ml-md q-mb-sm">
{{ loc.address_line }}, {{ loc.city }} {{ loc.postal_code }}
</div>
<!-- Subscriptions for this location -->
<div v-if="loc.subscriptions.length" class="q-ml-md">
<div v-for="sub in loc.subscriptions" :key="sub.name" class="subscription-row">
<div class="row items-center no-wrap">
<q-icon :name="categoryIcon(sub.service_category)" size="16px" :color="categoryColor(sub.service_category)" class="q-mr-sm" />
<div class="col">
<div class="text-body2">{{ sub.plan_name }}</div>
<div v-if="sub.speed_down" class="text-caption text-grey-6">
{{ sub.speed_down }} / {{ sub.speed_up }} Mbps
</div>
</div>
<div class="text-right">
<div class="text-body2 text-weight-medium" :class="sub.monthly_price < 0 ? 'text-positive' : ''">
{{ formatMoney(sub.monthly_price) }}
</div>
<div class="text-caption text-grey-6">/mois</div>
</div>
</div>
</div>
<!-- Location subtotal -->
<div class="location-subtotal q-mt-xs">
<div class="row items-center justify-end">
<span class="text-caption text-grey-7 q-mr-sm">Sous-total:</span>
<span class="text-body2 text-weight-bold">{{ formatMoney(loc.monthly_total) }}/mois</span>
</div>
</div>
</div>
<div v-else class="text-caption text-grey-5 q-ml-md">Aucun service actif</div>
</div>
</div>
</div>
</div>
<!-- Payment methods -->
<div class="col-12">
<div class="portal-card">
<div class="row items-center q-mb-md">
<q-icon name="credit_card" size="24px" color="indigo" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-medium">Paiement</div>
<q-space />
<q-btn outline color="indigo" label="Ajouter une carte" icon="add_card" no-caps size="sm"
:loading="addingCard" @click="addCard" />
<q-btn v-if="hasStripeCards" flat color="grey-7" label="Gerer les cartes" icon="settings" no-caps size="sm"
class="q-ml-sm" :loading="openingPortal" @click="openPortal" />
</div>
<!-- Balance -->
<div v-if="balance !== null" class="q-mb-md">
<div class="row items-center q-gutter-sm">
<span class="text-body2">Solde a payer:</span>
<span class="text-h6 text-weight-bold" :class="balance > 0 ? 'text-negative' : 'text-positive'">
{{ formatMoney(balance) }}
</span>
<q-btn v-if="balance > 0" color="indigo" unelevated label="Payer le solde" icon="payment"
no-caps size="sm" :loading="payingBalance" @click="payBalance" class="q-ml-md" />
</div>
</div>
<!-- Saved cards -->
<div v-if="loadingPayment" class="q-pa-sm">
<q-spinner-dots size="24px" color="indigo" />
</div>
<div v-else-if="cards.length" class="q-mb-md">
<div class="text-caption text-weight-bold text-grey-7 q-mb-xs">Cartes enregistrees</div>
<q-list bordered separator class="rounded-borders">
<q-item v-for="card in cards" :key="card.id">
<q-item-section avatar>
<q-icon :name="cardIcon(card.brand)" size="28px" :color="card.is_default ? 'indigo' : 'grey-6'" />
</q-item-section>
<q-item-section>
<q-item-label>{{ cardBrandLabel(card.brand) }} **** {{ card.last4 }}</q-item-label>
<q-item-label caption>Exp. {{ card.exp_month }}/{{ card.exp_year }}</q-item-label>
</q-item-section>
<q-item-section side v-if="card.is_default">
<q-badge color="indigo" label="Par defaut" />
</q-item-section>
</q-item>
</q-list>
</div>
<div v-else class="text-grey-6 text-body2 q-mb-md">
Aucune carte enregistree. Ajoutez une carte pour activer le paiement automatique.
</div>
<!-- PPA toggle -->
<div class="q-pa-sm rounded-borders" style="background: #f8f9fc;">
<div class="row items-center">
<div class="col">
<div class="text-weight-medium">Paiement automatique (PPA)</div>
<div class="text-caption text-grey-7">
Vos factures seront payees automatiquement avec votre carte par defaut.
</div>
</div>
<div class="col-auto">
<q-toggle v-model="ppaEnabled" color="indigo" :disable="!hasStripeCards || togglingPPA"
@update:model-value="onTogglePPA" />
</div>
</div>
</div>
</div>
</div>
</div>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { useCustomerStore } from 'src/stores/customer'
import { fetchProfile, fetchServiceLocations } from 'src/api/portal'
import { getBalance, getPaymentMethods, checkoutBalance, setupCard, openBillingPortal, togglePPA } from 'src/api/payments'
import { useFormatters } from 'src/composables/useFormatters'
const $q = useQuasar()
const store = useCustomerStore()
const { formatMoney } = useFormatters()
const profile = ref(null)
const serviceLocations = ref([])
const grandMonthlyTotal = ref(null)
const subscriptionCount = ref(0)
const loadingServices = ref(true)
const balance = ref(null)
const cards = ref([])
const ppaEnabled = ref(false)
const loadingPayment = ref(true)
const addingCard = ref(false)
const openingPortal = ref(false)
const payingBalance = ref(false)
const togglingPPA = ref(false)
const hasStripeCards = computed(() => cards.value.length > 0)
function cardIcon (brand) {
const icons = { visa: 'credit_card', mastercard: 'credit_card', amex: 'credit_card' }
return icons[brand] || 'credit_card'
}
function cardBrandLabel (brand) {
const labels = { visa: 'Visa', mastercard: 'Mastercard', amex: 'Amex', discover: 'Discover' }
return labels[brand] || (brand || 'Carte').charAt(0).toUpperCase() + (brand || 'carte').slice(1)
}
async function loadPaymentInfo () {
if (!store.customerId) return
loadingPayment.value = true
try {
const [balRes, methRes] = await Promise.all([
getBalance(store.customerId),
getPaymentMethods(store.customerId),
])
balance.value = balRes.balance || 0
// Extract cards from Stripe methods
const methods = methRes.methods || []
const stripeMethod = methods.find(m => m.provider === 'Stripe')
cards.value = stripeMethod?.stripe_cards || []
ppaEnabled.value = !!(stripeMethod?.is_auto_ppa)
} catch (e) {
console.error('Payment info load error:', e)
} finally {
loadingPayment.value = false
}
}
async function addCard () {
addingCard.value = true
try {
const result = await setupCard(store.customerId)
if (result.url) window.location.href = result.url
} catch (e) {
$q.notify({ message: 'Erreur: ' + e.message, color: 'negative' })
} finally {
addingCard.value = false
}
}
async function openPortal () {
openingPortal.value = true
try {
const result = await openBillingPortal(store.customerId)
if (result.url) window.location.href = result.url
} catch (e) {
$q.notify({ message: 'Erreur: ' + e.message, color: 'negative' })
} finally {
openingPortal.value = false
}
}
async function payBalance () {
payingBalance.value = true
try {
const result = await checkoutBalance(store.customerId)
if (result.url) window.location.href = result.url
} catch (e) {
$q.notify({ message: 'Erreur: ' + e.message, color: 'negative' })
} finally {
payingBalance.value = false
}
}
async function onTogglePPA (val) {
togglingPPA.value = true
try {
await togglePPA(store.customerId, val)
$q.notify({
message: val ? 'Paiement automatique active' : 'Paiement automatique desactive',
color: 'positive', icon: val ? 'check_circle' : 'info',
})
} catch (e) {
ppaEnabled.value = !val // revert
$q.notify({ message: 'Erreur: ' + e.message, color: 'negative' })
} finally {
togglingPPA.value = false
}
}
function categoryIcon (cat) {
const icons = {
Internet: 'wifi', IPTV: 'tv', VoIP: 'phone', Bundle: 'inventory_2',
'Hébergement': 'dns', Autre: 'category',
}
return icons[cat] || 'category'
}
function categoryColor (cat) {
const colors = {
Internet: 'blue', IPTV: 'purple', VoIP: 'green', Bundle: 'orange',
'Hébergement': 'cyan', Autre: 'grey',
}
return colors[cat] || 'grey'
}
onMounted(async () => {
if (!store.customerId) return
const [p, svcResult] = await Promise.all([
fetchProfile(store.customerId),
fetchServiceLocations(store.customerId).catch(() => ({ locations: [], grandTotal: 0, subscriptionCount: 0 })),
])
profile.value = p
serviceLocations.value = svcResult.locations
grandMonthlyTotal.value = svcResult.grandTotal
subscriptionCount.value = svcResult.subscriptionCount
loadingServices.value = false
loadPaymentInfo()
})
</script>

View File

@ -0,0 +1,301 @@
<template>
<q-page class="cart-page q-pa-md">
<div style="max-width:900px;margin:0 auto">
<div v-if="cart.items.length === 0" class="text-center q-pa-xl">
<q-icon name="shopping_cart" size="80px" color="grey-4" />
<div class="text-h5 text-grey-6 q-mt-lg">Votre panier est vide</div>
<q-btn color="primary" unelevated label="Voir le catalogue" class="q-mt-lg" @click="$router.push('/catalogue')" />
</div>
<template v-else>
<div class="row items-center q-mb-lg">
<q-btn flat round icon="arrow_back" @click="$router.push('/catalogue')" />
<div class="text-h5 text-weight-bold q-ml-sm">Mon panier</div>
<q-space />
<q-btn flat color="negative" label="Vider le panier" icon="delete_sweep" @click="confirmClear" />
</div>
<div class="row q-col-gutter-lg">
<div class="col-12 col-md-7">
<!-- Cart items -->
<q-card flat bordered class="q-mb-md rounded-card">
<q-list separator>
<q-item v-for="(item, idx) in cart.items" :key="item.item_code" class="q-pa-md">
<q-item-section>
<q-item-label class="text-weight-bold">{{ item.item_name }}</q-item-label>
<q-item-label caption>{{ item.billing_type === 'Mensuel' ? 'Mensuel' : 'Achat unique' }}</q-item-label>
<q-item-label caption v-if="item.description" class="q-mt-xs">{{ item.description }}</q-item-label>
</q-item-section>
<q-item-section side class="row items-center q-gutter-sm" style="flex-direction:row">
<q-btn flat dense round icon="remove" size="sm" @click="cart.updateQty(idx, item.qty - 1)" />
<span class="text-weight-medium q-mx-xs">{{ item.qty }}</span>
<q-btn flat dense round icon="add" size="sm" @click="cart.updateQty(idx, item.qty + 1)" />
<span class="text-weight-bold q-ml-md" style="min-width:80px;text-align:right">{{ formatPrice(item.rate * item.qty) }}</span>
<q-btn flat dense round icon="close" color="negative" size="sm" class="q-ml-sm" @click="cart.removeItem(idx)" />
</q-item-section>
</q-item>
</q-list>
</q-card>
<!-- Installation scheduling -->
<q-card v-if="cart.requiresVisit" flat bordered class="q-mb-md rounded-card">
<q-card-section>
<div class="text-subtitle1 text-weight-bold q-mb-sm">
<q-icon name="calendar_month" class="q-mr-xs" /> Installation requise
</div>
<div class="text-body2 text-grey-7 q-mb-md">Certains articles nécessitent une visite d'installation.</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-sm-6">
<q-input v-model="form.preferredDate" type="date" label="Date préférée" outlined dense :min="minDate" :rules="[v => !!v || 'Requis']" />
</div>
<div class="col-12 col-sm-6">
<q-select v-model="form.preferredSlot" :options="TIME_SLOTS" label="Plage horaire" outlined dense emit-value map-options />
</div>
</div>
</q-card-section>
</q-card>
<!-- OTP existing customer -->
<q-card flat bordered class="q-mb-md rounded-card">
<q-card-section>
<div class="row items-center q-mb-sm">
<q-icon name="person_search" size="22px" color="primary" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Vous êtes déjà client?</div>
</div>
<div class="text-body2 text-grey-7 q-mb-md">Entrez votre courriel ou téléphone pour associer cette commande à votre compte.</div>
<div v-if="!otp.verified.value">
<div v-if="otp.step.value === 'verify'" class="text-caption text-grey-7 q-mb-xs">
Code envoyé par {{ otp.channel.value === 'email' ? 'courriel' : 'SMS' }} au <strong>{{ otp.identifier.value }}</strong>
· <a class="cursor-pointer text-primary" style="text-decoration:none" @click="otp.reset()">Modifier</a>
</div>
<div class="row q-col-gutter-sm items-center">
<div class="col">
<q-input v-if="otp.step.value === 'idle'" v-model="otp.identifier.value" label="Courriel ou téléphone" outlined dense placeholder="ex: 514-555-1234 ou nom@exemple.com" @keyup.enter="otp.identifier.value.length >= 5 && otp.send()">
<template v-slot:prepend><q-icon name="person_search" size="20px" /></template>
</q-input>
<q-input v-else ref="otpInputRef" v-model="otp.code.value" label="Code de vérification" outlined dense type="tel" maxlength="6" placeholder="000000" input-class="text-weight-bold text-center" input-style="font-size:1.3rem;letter-spacing:8px" @keyup.enter="otp.code.value.length === 6 && otp.verify()">
<template v-slot:prepend><q-icon name="lock" size="20px" /></template>
</q-input>
</div>
<div class="col-auto">
<q-btn color="primary" unelevated
:label="otp.step.value === 'idle' ? 'Envoyer' : 'Valider'"
:icon="otp.step.value === 'idle' ? 'send' : 'check'"
:loading="otp.step.value === 'idle' ? otp.sending.value : otp.verifying.value"
:disable="otp.step.value === 'idle' ? (!otp.identifier.value || otp.identifier.value.length < 5) : otp.code.value.length !== 6"
@click="otp.step.value === 'idle' ? otp.send() : otp.verify()" />
</div>
</div>
<div v-if="otp.step.value === 'verify'" class="q-mt-xs">
<q-btn flat dense size="sm" color="grey-7" label="Renvoyer le code" icon="refresh" @click="otp.send()" :loading="otp.sending.value" no-caps />
</div>
<div v-if="otp.error.value" class="text-negative text-caption q-mt-xs">
<q-icon name="error" size="14px" /> {{ otp.error.value }}
</div>
</div>
<!-- Verified state -->
<div v-if="otp.verified.value" class="q-mt-sm">
<q-banner dense rounded class="bg-green-1 text-positive">
<template v-slot:avatar><q-icon name="verified_user" color="positive" /></template>
<strong>{{ form.name }}</strong> compte vérifié
<template v-slot:action><q-btn flat dense label="Changer" @click="otp.reset()" /></template>
</q-banner>
<div v-if="otp.addresses.value.length" class="q-mt-md">
<div class="text-caption text-weight-bold q-mb-xs">Vos adresses :</div>
<q-list bordered separator class="rounded-borders">
<q-item v-for="(addr, i) in otp.addresses.value" :key="i" clickable v-ripple
@click="addr$.selectCustomerAddr(addr)"
:class="form.address === addr.address ? 'bg-blue-1' : ''">
<q-item-section avatar>
<q-icon :name="form.address === addr.address ? 'radio_button_checked' : 'radio_button_unchecked'" :color="form.address === addr.address ? 'primary' : 'grey-5'" />
</q-item-section>
<q-item-section>
<q-item-label>{{ addr.address }}</q-item-label>
<q-item-label caption>{{ addr.city }}{{ addr.postal_code ? ' — ' + addr.postal_code : '' }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<div class="text-caption text-grey-6 q-mt-xs">Ou recherchez une nouvelle adresse ci-dessous</div>
</div>
</div>
<div v-if="otp.step.value === 'idle' && !otp.verified.value" class="text-caption text-grey-6 q-mt-sm">
Pas encore client? Continuez comme nouveau client ci-dessous.
</div>
</q-card-section>
</q-card>
<!-- Customer info -->
<q-card flat bordered class="q-mb-md rounded-card">
<q-card-section>
<div class="text-subtitle1 text-weight-bold q-mb-md"><q-icon name="person" class="q-mr-xs" /> Vos coordonnées</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-sm-6">
<q-input v-model="form.name" label="Nom complet" outlined dense :rules="[v => !!v || 'Requis']" />
</div>
<div class="col-12 col-sm-6">
<q-input v-model="form.phone" label="Téléphone" outlined dense mask="(###) ###-####" :rules="[v => !!v || 'Requis']" />
</div>
<div class="col-12">
<q-input v-model="form.email" label="Courriel" outlined dense type="email" :rules="[v => !!v || 'Requis', v => /.+@.+\..+/.test(v) || 'Courriel invalide']" />
</div>
<div class="col-12">
<q-input v-model="addr$.query.value" label="Adresse d'installation / livraison" outlined dense :loading="addr$.searching.value" @update:model-value="addr$.onInput" :rules="[() => !!form.address || 'Sélectionnez une adresse']">
<template v-slot:prepend><q-icon name="place" /></template>
<template v-slot:append><q-icon v-if="form.address" name="check_circle" color="positive" /></template>
</q-input>
<q-list v-if="addr$.results.value.length && !addr$.selected.value" bordered separator class="addr-dropdown">
<q-item v-for="(a, i) in addr$.results.value" :key="i" clickable v-ripple @click="addr$.selectResult(a)">
<q-item-section avatar><q-icon name="location_on" :color="a.fiber_available ? 'positive' : 'grey-5'" /></q-item-section>
<q-item-section>
<q-item-label>{{ a.adresse_formatee }}</q-item-label>
<q-item-label caption>{{ a.nom_municipalite }}{{ a.code_postal ? ' — ' + a.code_postal : '' }} <q-badge v-if="a.fiber_available" color="positive" label="Fibre" class="q-ml-xs" /></q-item-label>
</q-item-section>
</q-item>
</q-list>
<div v-if="addr$.selected.value && form.address" class="text-caption text-grey-7 q-mt-xs q-ml-sm">
<q-icon name="check" color="positive" size="14px" /> {{ form.address }}{{ form.city ? ', ' + form.city : '' }}{{ form.postalCode ? ' ' + form.postalCode : '' }}
</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
<!-- Order summary -->
<div class="col-12 col-md-5">
<q-card flat bordered class="summary-card rounded-card" style="position:sticky;top:80px">
<q-card-section>
<div class="text-subtitle1 text-weight-bold q-mb-md">Sommaire de commande</div>
<div v-for="item in cart.items" :key="item.item_code" class="row justify-between q-mb-xs text-body2">
<span>{{ item.item_name }} x{{ item.qty }}</span>
<span>{{ formatPrice(item.rate * item.qty) }}</span>
</div>
<q-separator class="q-my-md" />
<div v-if="cart.recurringTotal > 0" class="row justify-between q-mb-xs">
<span class="text-body2">Mensuel</span>
<span class="text-body2 text-weight-medium">{{ formatPrice(cart.recurringTotal) }}/mois</span>
</div>
<div v-if="cart.onetimeTotal > 0" class="row justify-between q-mb-xs">
<span class="text-body2">Achat unique</span>
<span class="text-body2 text-weight-medium">{{ formatPrice(cart.onetimeTotal) }}</span>
</div>
<div class="row justify-between q-mb-xs">
<span class="text-body2">Sous-total</span>
<span class="text-body2 text-weight-medium">{{ formatPrice(cart.subtotal) }}</span>
</div>
<div class="row justify-between q-mb-xs">
<span class="text-body2 text-grey-6">TPS + TVQ (14,975%)</span>
<span class="text-body2 text-grey-6">{{ formatPrice(cart.taxAmount) }}</span>
</div>
<q-separator class="q-my-md" />
<div class="row justify-between">
<span class="text-subtitle1 text-weight-bold">Total</span>
<span class="text-subtitle1 text-weight-bold text-primary">{{ formatPrice(cart.grandTotal) }}</span>
</div>
</q-card-section>
<q-separator />
<q-card-section>
<q-checkbox v-model="form.acceptTerms" dense class="q-mb-md">
<template #default>
<span class="text-body2">
J'accepte les <a href="#" class="text-primary" @click.prevent>conditions d'utilisation</a>
et la <a href="#" class="text-primary" @click.prevent>politique de confidentialité</a>
</span>
</template>
</q-checkbox>
<div class="text-caption text-grey-6 q-mb-md">Aucun paiement requis maintenant. Vous recevrez votre première facture après l'activation du service.</div>
<q-btn color="primary" unelevated class="full-width" size="lg" label="Commander" icon="check_circle" :loading="submitting" :disable="!canSubmit" @click="handleSubmit" />
</q-card-section>
</q-card>
</div>
</div>
</template>
</div>
</q-page>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { useCartStore } from 'src/stores/cart'
import { useCustomerStore } from 'src/stores/customer'
import { submitOrder } from 'src/api/catalog'
import { useOTP } from 'src/composables/useOTP'
import { useAddressSearch } from 'src/composables/useAddressSearch'
import { formatPrice, minBookingDate } from 'src/utils/format'
import { TIME_SLOTS } from 'src/data/catalog'
const $q = useQuasar()
const router = useRouter()
const cart = useCartStore()
const customer = useCustomerStore()
const submitting = ref(false)
const minDate = computed(() => minBookingDate())
const form = reactive({
name: '', phone: '', email: '', address: '', city: '', province: 'QC',
postalCode: '', latitude: null, longitude: null,
preferredDate: minBookingDate(), preferredSlot: 'AM',
acceptTerms: false, customer_id: null,
})
const addr$ = useAddressSearch(form)
const otp = useOTP(form, {
onVerified (result) {
if (result.addresses?.length) addr$.selectCustomerAddr(result.addresses[0])
$q.notify({ message: 'Compte vérifié!', color: 'positive', icon: 'verified_user' })
},
})
onMounted(() => {
if (customer.customerName) form.name = customer.customerName
if (customer.email) form.email = customer.email
})
const canSubmit = computed(() => {
const base = form.name && form.phone && form.email && form.address && form.acceptTerms
return cart.requiresVisit ? base && form.preferredDate : base
})
function confirmClear () {
$q.dialog({
title: 'Vider le panier', message: 'Voulez-vous vraiment retirer tous les articles?',
cancel: { label: 'Annuler', flat: true }, ok: { label: 'Vider', color: 'negative' },
}).onOk(() => cart.clearCart())
}
async function handleSubmit () {
submitting.value = true
try {
const payload = {
customer_id: form.customer_id || customer.customerId || null,
items: cart.items.map(({ item_code, item_name, qty, rate, billing_type, requires_visit, project_template_id }) =>
({ item_code, item_name, qty, rate, billing_type, requires_visit, project_template_id })),
contact: { name: form.name, phone: form.phone, email: form.email, address: form.address, city: form.city, province: form.province, postal_code: form.postalCode, latitude: form.latitude, longitude: form.longitude },
installation: cart.requiresVisit ? { preferred_date: form.preferredDate, preferred_slot: form.preferredSlot } : null,
totals: { subtotal: cart.subtotal, tax: cart.taxAmount, grand_total: cart.grandTotal, recurring_monthly: cart.recurringTotal, onetime: cart.onetimeTotal },
}
const result = await submitOrder(payload)
router.push({ name: 'order-success', query: { order: result.order_id }, state: { orderData: JSON.stringify({ ...payload, order_id: result.order_id }) } })
cart.clearCart()
} catch (e) {
$q.notify({ message: 'Erreur : ' + (e.message || 'Veuillez réessayer'), color: 'negative', icon: 'error' })
} finally { submitting.value = false }
}
</script>
<style lang="scss" scoped>
.cart-page { padding-bottom: 32px }
.rounded-card { border-radius: 12px }
.summary-card { background: #fafbff }
.addr-dropdown { position: relative; z-index: 10; background: #fff; box-shadow: 0 4px 16px rgba(0,0,0,.1); max-height: 280px; overflow-y: auto; border-radius: 0 0 8px 8px; margin-top: -8px }
</style>

View File

@ -0,0 +1,109 @@
<template>
<q-page class="catalog-page">
<div class="catalog-hero q-pa-lg text-center text-white">
<div class="text-h4 text-weight-bold q-mb-sm">Nos forfaits et services</div>
<div class="text-subtitle1" style="opacity:.85">Internet fibre ultra-rapide, téléphonie et plus</div>
</div>
<div class="q-px-md q-pt-md" style="max-width:1200px;margin:0 auto">
<q-tabs v-model="activeTab" dense active-color="primary" indicator-color="primary" align="left" narrow-indicator class="text-grey-7">
<q-tab name="all" label="Tous" />
<q-tab name="Internet" label="Internet" />
<q-tab name="Téléphonie" label="Téléphonie" />
<q-tab name="Bundle" label="Bundles" />
<q-tab name="Équipement" label="Équipement" />
</q-tabs>
</div>
<div class="q-pa-md" style="max-width:1200px;margin:0 auto">
<div class="row q-col-gutter-md">
<div v-for="p in filteredProducts" :key="p.item_code" class="col-12 col-sm-6 col-md-4 col-lg-3">
<q-card class="product-card full-height" flat bordered>
<q-badge v-if="p.popular" color="orange" floating label="Populaire" class="text-weight-bold" style="top:12px;right:12px;font-size:.75rem" />
<q-card-section>
<q-chip :color="CATEGORY_COLORS[p.service_category] || 'grey'" text-color="white" size="sm" dense class="q-mb-sm">{{ p.service_category }}</q-chip>
<div class="text-h6 text-weight-bold q-mb-xs">{{ p.item_name }}</div>
<div v-if="p.speed_down" class="speed-bar q-mb-sm">
<div class="row items-center q-gutter-xs">
<q-icon name="speed" color="primary" size="20px" />
<span class="text-weight-medium text-primary">{{ p.speed_down }} / {{ p.speed_up }} Mbps</span>
</div>
<q-linear-progress :value="p.speed_down / 1000" color="primary" class="q-mt-xs" rounded size="6px" />
</div>
<div class="text-body2 text-grey-7 q-mb-md" style="min-height:40px">{{ p.description }}</div>
<div v-if="p.bundle_includes?.length" class="q-mb-md">
<div class="text-caption text-weight-medium text-grey-8 q-mb-xs">Inclus :</div>
<div v-for="inc in p.bundle_includes" :key="inc" class="row items-center q-gutter-xs q-mb-xs">
<q-icon name="check_circle" color="positive" size="16px" />
<span class="text-body2">{{ inc }}</span>
</div>
</div>
<div class="row items-end q-mb-sm">
<span class="text-h5 text-weight-bold text-primary">{{ formatPrice(p.rate) }}</span>
<span class="text-body2 text-grey-6 q-ml-xs q-mb-xs">{{ p.billing_type === 'Mensuel' ? '/mois' : 'unique' }}</span>
</div>
<div v-if="p.requires_visit" class="text-caption text-grey-6 q-mb-sm">
<q-icon name="build" size="14px" class="q-mr-xs" /> Installation requise
</div>
</q-card-section>
<q-separator />
<q-card-actions class="q-pa-md">
<q-btn color="primary" unelevated class="full-width" :label="isInCart(p.item_code) ? 'Ajouté ✓' : 'Ajouter au panier'" :outline="isInCart(p.item_code)" @click="addToCart(p)" />
</q-card-actions>
</q-card>
</div>
</div>
<div v-if="!filteredProducts.length" class="text-center q-pa-xl text-grey-5">
<q-icon name="inventory_2" size="64px" />
<div class="text-h6 q-mt-md">Aucun produit dans cette catégorie</div>
</div>
</div>
<transition name="slide-up">
<div v-if="cart.itemCount > 0" class="cart-bar">
<div class="cart-bar-inner row items-center justify-between q-px-md">
<div class="row items-center q-gutter-sm">
<q-icon name="shopping_cart" size="24px" color="white" />
<span class="text-white text-weight-medium">{{ cart.itemCount }} article{{ cart.itemCount > 1 ? 's' : '' }}</span>
<q-separator vertical dark class="q-mx-sm" />
<span v-if="cart.recurringTotal > 0" class="text-white">{{ formatPrice(cart.recurringTotal) }}/mois</span>
<span v-if="cart.onetimeTotal > 0" class="text-white q-ml-sm">+ {{ formatPrice(cart.onetimeTotal) }} unique</span>
</div>
<q-btn color="white" text-color="primary" unelevated label="Voir le panier" icon-right="arrow_forward" @click="$router.push('/panier')" class="text-weight-bold" />
</div>
</div>
</transition>
</q-page>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useCartStore } from 'src/stores/cart'
import { useQuasar } from 'quasar'
import { CATALOG, CATEGORY_COLORS } from 'src/data/catalog'
import { formatPrice } from 'src/utils/format'
const $q = useQuasar()
const cart = useCartStore()
const activeTab = ref('all')
const filteredProducts = computed(() =>
activeTab.value === 'all' ? CATALOG : CATALOG.filter(p => p.service_category === activeTab.value)
)
const isInCart = (code) => cart.items.some(i => i.item_code === code)
function addToCart (product) {
cart.addItem(product)
$q.notify({ message: `${product.item_name} ajouté au panier`, color: 'positive', icon: 'check_circle', position: 'bottom', timeout: 1500 })
}
</script>
<style lang="scss" scoped>
.catalog-hero { background: linear-gradient(135deg, #3949ab 0%, #1a237e 100%); margin: -16px -16px 0 -16px; padding: 40px 16px 32px }
.product-card { border-radius: 12px; transition: box-shadow 0.2s, transform 0.2s; &:hover { box-shadow: 0 8px 30px rgba(0,0,0,.1); transform: translateY(-2px) } }
.cart-bar { position: fixed; bottom: 0; left: 0; right: 0; z-index: 2000; background: linear-gradient(135deg, #3949ab 0%, #1a237e 100%); box-shadow: 0 -4px 20px rgba(0,0,0,.2) }
.cart-bar-inner { max-width: 1200px; margin: 0 auto; min-height: 56px }
.slide-up-enter-active, .slide-up-leave-active { transition: transform 0.3s ease }
.slide-up-enter-from, .slide-up-leave-to { transform: translateY(100%) }
</style>

View File

@ -0,0 +1,116 @@
<template>
<q-page padding>
<div class="page-title">Bonjour, {{ store.customerName }}</div>
<div class="row q-col-gutter-md">
<!-- Unpaid invoices -->
<div class="col-12 col-sm-6 col-md-4">
<div class="portal-card">
<div class="text-caption text-grey-7">Factures impayees</div>
<div class="summary-value">{{ unpaidCount }}</div>
<div v-if="unpaidTotal > 0" class="text-body2 text-grey-7">
{{ formatMoney(unpaidTotal) }} a payer
</div>
<div class="row q-gutter-sm q-mt-sm">
<q-btn flat color="primary" label="Voir les factures" to="/invoices" no-caps />
<q-btn v-if="unpaidTotal > 0" unelevated color="indigo" label="Payer" icon="payment"
no-caps size="sm" :loading="payLoading" @click="payBalance" />
</div>
</div>
</div>
<!-- Open tickets -->
<div class="col-12 col-sm-6 col-md-4">
<div class="portal-card">
<div class="text-caption text-grey-7">Tickets ouverts</div>
<div class="summary-value">{{ openTickets }}</div>
<q-btn flat color="primary" label="Voir le support" to="/tickets" class="q-mt-sm" no-caps />
</div>
</div>
<!-- Quick action -->
<div class="col-12 col-sm-6 col-md-4">
<div class="portal-card">
<div class="text-caption text-grey-7">Besoin d'aide?</div>
<div class="text-h6 q-mt-xs">Contactez-nous</div>
<q-btn flat color="primary" label="Nouveau ticket" to="/tickets" class="q-mt-sm" no-caps icon="add" />
</div>
</div>
</div>
<!-- Recent invoices -->
<div v-if="recentInvoices.length" class="q-mt-lg">
<div class="text-subtitle1 text-weight-medium q-mb-sm">Dernières factures</div>
<q-list bordered separator class="rounded-borders bg-white">
<q-item v-for="inv in recentInvoices" :key="inv.name" clickable @click="$router.push('/invoices/' + inv.name)">
<q-item-section>
<q-item-label>{{ inv.name }}</q-item-label>
<q-item-label caption>{{ formatDate(inv.posting_date) }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label>{{ formatMoney(inv.grand_total) }}</q-item-label>
<q-item-label caption :class="statusClass(inv.status)">
{{ inv.status }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useQuasar } from 'quasar'
import { useCustomerStore } from 'src/stores/customer'
import { fetchInvoices, fetchTickets } from 'src/api/portal'
import { checkoutBalance } from 'src/api/payments'
import { useFormatters } from 'src/composables/useFormatters'
const $q = useQuasar()
const store = useCustomerStore()
const { formatDate, formatMoney } = useFormatters()
const recentInvoices = ref([])
const allTickets = ref([])
const payLoading = ref(false)
const unpaidCount = computed(() =>
recentInvoices.value.filter(i => i.outstanding_amount > 0).length,
)
const unpaidTotal = computed(() =>
recentInvoices.value.filter(i => i.outstanding_amount > 0)
.reduce((sum, i) => sum + i.outstanding_amount, 0),
)
const openTickets = computed(() =>
allTickets.value.filter(t => t.status === 'Open').length,
)
function statusClass (status) {
if (status === 'Paid') return 'status-paid'
if (status === 'Unpaid' || status === 'Overdue') return 'status-unpaid'
return ''
}
async function payBalance () {
payLoading.value = true
try {
const result = await checkoutBalance(store.customerId)
if (result.url) window.location.href = result.url
} catch (e) {
$q.notify({ message: 'Erreur: ' + (e.message || 'Paiement impossible'), color: 'negative' })
} finally {
payLoading.value = false
}
}
onMounted(async () => {
if (!store.customerId) return
const [invoices, tickets] = await Promise.all([
fetchInvoices(store.customerId, { pageSize: 5 }),
fetchTickets(store.customerId),
])
recentInvoices.value = invoices
allTickets.value = tickets
})
</script>

View File

@ -0,0 +1,232 @@
<template>
<q-page padding>
<!-- Header -->
<div class="flex items-center q-mb-md">
<q-btn flat round icon="arrow_back" @click="$router.push('/invoices')" />
<div class="page-title q-mb-none q-ml-sm">{{ invoiceName }}</div>
<q-space />
<q-btn outline color="primary" icon="picture_as_pdf" label="PDF"
:loading="downloadingPDF" @click="downloadPDF" no-caps class="q-mr-sm" size="sm" />
</div>
<!-- Payment CTA for unpaid invoices -->
<q-banner v-if="invoice && canPay" rounded class="bg-indigo-1 q-mb-md">
<template #avatar><q-icon name="payment" color="indigo" /></template>
<div class="row items-center">
<div class="col">
<div class="text-weight-medium">Solde a payer: <strong class="text-negative">{{ formatMoney(invoice.outstanding_amount) }}</strong></div>
<div class="text-caption text-grey-7">Echeance: {{ formatDate(invoice.due_date) }}</div>
</div>
<div class="col-auto q-gutter-sm">
<q-btn color="indigo" unelevated label="Payer maintenant" icon="credit_card"
:loading="payLoading" @click="payInvoice('card')" no-caps />
<q-btn outline color="pink-7" label="Payer avec Klarna" icon="account_balance"
:loading="payKlarnaLoading" @click="payInvoice('klarna')" no-caps size="sm" />
</div>
</div>
<div class="q-mt-xs">
<q-checkbox v-model="saveCard" dense size="sm">
<span class="text-caption">Sauvegarder ma carte pour les prochains paiements automatiques</span>
</q-checkbox>
</div>
</q-banner>
<!-- Summary bar -->
<div v-if="invoice" class="row q-col-gutter-sm q-mb-md">
<div class="col-6 col-sm-3">
<div class="portal-card text-center">
<div class="text-caption text-grey-7">Total</div>
<div class="text-h6">{{ formatMoney(invoice.grand_total) }}</div>
</div>
</div>
<div class="col-6 col-sm-3">
<div class="portal-card text-center">
<div class="text-caption text-grey-7">Solde</div>
<div class="text-h6" :class="invoice.outstanding_amount > 0 ? 'text-negative' : 'text-positive'">
{{ formatMoney(invoice.outstanding_amount) }}
</div>
</div>
</div>
<div class="col-6 col-sm-3">
<div class="portal-card text-center">
<div class="text-caption text-grey-7">Date</div>
<div class="text-body1">{{ formatDate(invoice.posting_date) }}</div>
</div>
</div>
<div class="col-6 col-sm-3">
<div class="portal-card text-center">
<div class="text-caption text-grey-7">Statut</div>
<q-badge :color="statusColor(invoice.status)" :label="statusLabel(invoice.status)" class="q-mt-xs" />
</div>
</div>
</div>
<!-- Line items table -->
<div v-if="invoice && invoice.items" class="q-mb-md">
<div class="text-subtitle1 text-weight-medium q-mb-sm">Details</div>
<q-table
:rows="invoice.items"
:columns="itemColumns"
row-key="name"
flat bordered
class="bg-white"
hide-pagination
:pagination="{ rowsPerPage: 0 }"
>
<template #body-cell-rate="props">
<q-td :props="props" class="text-right">{{ formatMoney(props.value) }}</q-td>
</template>
<template #body-cell-amount="props">
<q-td :props="props" class="text-right text-weight-medium">{{ formatMoney(props.value) }}</q-td>
</template>
</q-table>
<!-- Totals -->
<div class="bg-white q-pa-md rounded-borders" style="border: 1px solid #e0e0e0; border-top: none;">
<div class="row justify-end q-gutter-sm">
<div v-if="invoice.total_taxes_and_charges" class="text-right">
<span class="text-grey-7">Taxes: </span>
<span class="text-weight-medium">{{ formatMoney(invoice.total_taxes_and_charges) }}</span>
</div>
<div class="text-right q-ml-lg">
<span class="text-grey-7">Grand total: </span>
<span class="text-h6 text-weight-bold">{{ formatMoney(invoice.grand_total) }}</span>
</div>
</div>
</div>
</div>
<!-- Jinja rendered preview -->
<div v-if="printHTML" class="q-mb-md">
<div class="text-subtitle1 text-weight-medium q-mb-sm">Apercu de la facture</div>
<div class="bg-white q-pa-md rounded-borders invoice-preview" style="border: 1px solid #e0e0e0;">
<div v-html="printHTML" />
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex flex-center q-pa-xl">
<q-spinner-dots size="48px" color="primary" />
</div>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { useCustomerStore } from 'src/stores/customer'
import { fetchInvoice, fetchInvoiceHTML, fetchInvoicePDF } from 'src/api/portal'
import { checkoutInvoice } from 'src/api/payments'
import { useFormatters } from 'src/composables/useFormatters'
const route = useRoute()
const router = useRouter()
const $q = useQuasar()
const store = useCustomerStore()
const { formatDate, formatMoney } = useFormatters()
const invoiceName = route.params.name
const invoice = ref(null)
const printHTML = ref('')
const loading = ref(true)
const downloadingPDF = ref(false)
const payLoading = ref(false)
const payKlarnaLoading = ref(false)
const saveCard = ref(true)
const canPay = computed(() => {
if (!invoice.value) return false
return invoice.value.docstatus === 1 && invoice.value.outstanding_amount > 0
})
const itemColumns = [
{ name: 'item_name', label: 'Description', field: 'item_name', align: 'left' },
{ name: 'qty', label: 'Qte', field: 'qty', align: 'center' },
{ name: 'rate', label: 'Prix unitaire', field: 'rate', align: 'right' },
{ name: 'amount', label: 'Montant', field: 'amount', align: 'right' },
]
function statusColor (s) {
if (s === 'Paid') return 'positive'
if (s === 'Overdue') return 'negative'
if (s === 'Unpaid') return 'warning'
return 'grey'
}
function statusLabel (s) {
const map = { Paid: 'Payee', Unpaid: 'Impayee', Overdue: 'En retard', 'Partly Paid': 'Partielle' }
return map[s] || s
}
async function payInvoice (method) {
const loadRef = method === 'klarna' ? payKlarnaLoading : payLoading
loadRef.value = true
try {
const result = await checkoutInvoice(store.customerId, invoiceName, {
save_card: saveCard.value,
payment_method: method,
})
if (result.url) {
// Redirect to Stripe Checkout
window.location.href = result.url
}
} catch (e) {
$q.notify({ message: 'Erreur: ' + (e.message || 'Paiement impossible'), color: 'negative', icon: 'error' })
} finally {
loadRef.value = false
}
}
async function downloadPDF () {
downloadingPDF.value = true
try {
const blob = await fetchInvoicePDF(invoiceName)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${invoiceName}.pdf`
a.click()
URL.revokeObjectURL(url)
} finally {
downloadingPDF.value = false
}
}
onMounted(async () => {
if (!store.customerId) return
try {
const [inv, html] = await Promise.allSettled([
fetchInvoice(invoiceName),
fetchInvoiceHTML(invoiceName),
])
if (inv.status === 'fulfilled') {
// Security: verify this invoice belongs to the logged-in customer
if (inv.value.customer !== store.customerId) {
router.replace('/invoices')
return
}
invoice.value = inv.value
}
if (html.status === 'fulfilled' && html.value.html) {
printHTML.value = html.value.html
}
} finally {
loading.value = false
}
})
</script>
<style scoped>
.invoice-preview {
overflow-x: auto;
max-height: 80vh;
overflow-y: auto;
}
.invoice-preview :deep(table) {
width: 100%;
}
.invoice-preview :deep(img) {
max-width: 100%;
}
</style>

View File

@ -0,0 +1,135 @@
<template>
<q-page padding>
<div class="page-title">Factures</div>
<q-table
:rows="invoices"
:columns="columns"
row-key="name"
:loading="loading"
:pagination="pagination"
@request="onRequest"
@row-click="(evt, row) => router.push('/invoices/' + row.name)"
flat bordered
class="bg-white clickable-table"
no-data-label="Aucune facture"
>
<template #body-cell-posting_date="props">
<q-td :props="props">{{ formatShortDate(props.value) }}</q-td>
</template>
<template #body-cell-grand_total="props">
<q-td :props="props" class="text-right">{{ formatMoney(props.value) }}</q-td>
</template>
<template #body-cell-outstanding_amount="props">
<q-td :props="props" class="text-right">
<span :class="props.value > 0 ? 'status-unpaid' : 'status-paid'">
{{ formatMoney(props.value) }}
</span>
</q-td>
</template>
<template #body-cell-status="props">
<q-td :props="props">
<q-badge :color="statusColor(props.value)" :label="statusLabel(props.value)" />
</q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props">
<q-btn flat dense round icon="picture_as_pdf" color="primary"
@click="downloadPDF(props.row.name)" :loading="downloading === props.row.name" />
</q-td>
</template>
</q-table>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useCustomerStore } from 'src/stores/customer'
import { fetchInvoices, countInvoices, fetchInvoicePDF } from 'src/api/portal'
import { useFormatters } from 'src/composables/useFormatters'
const router = useRouter()
const store = useCustomerStore()
const { formatShortDate, formatMoney } = useFormatters()
const invoices = ref([])
const loading = ref(false)
const downloading = ref(null)
const totalCount = ref(0)
const pagination = ref({
page: 1,
rowsPerPage: 20,
rowsNumber: 0,
sortBy: 'posting_date',
descending: true,
})
const columns = [
{ name: 'name', label: 'No.', field: 'name', align: 'left', sortable: true },
{ name: 'posting_date', label: 'Date', field: 'posting_date', align: 'left', sortable: true },
{ name: 'grand_total', label: 'Total', field: 'grand_total', align: 'right', sortable: true },
{ name: 'outstanding_amount', label: 'Solde', field: 'outstanding_amount', align: 'right', sortable: true },
{ name: 'status', label: 'Statut', field: 'status', align: 'center' },
{ name: 'actions', label: '', field: 'actions', align: 'center' },
]
function statusColor (s) {
if (s === 'Paid') return 'positive'
if (s === 'Overdue') return 'negative'
if (s === 'Unpaid') return 'warning'
return 'grey'
}
function statusLabel (s) {
const map = { Paid: 'Payée', Unpaid: 'Impayée', Overdue: 'En retard', 'Partly Paid': 'Partielle' }
return map[s] || s
}
async function loadPage (page, pageSize) {
loading.value = true
try {
const data = await fetchInvoices(store.customerId, { page, pageSize })
invoices.value = data
} finally {
loading.value = false
}
}
async function onRequest (props) {
const { page, rowsPerPage } = props.pagination
await loadPage(page, rowsPerPage)
pagination.value.page = page
pagination.value.rowsPerPage = rowsPerPage
pagination.value.rowsNumber = totalCount.value
}
async function downloadPDF (name) {
downloading.value = name
try {
const blob = await fetchInvoicePDF(name)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${name}.pdf`
a.click()
URL.revokeObjectURL(url)
} catch (e) {
console.error('PDF download failed:', e)
} finally {
downloading.value = null
}
}
onMounted(async () => {
if (!store.customerId) return
totalCount.value = await countInvoices(store.customerId)
pagination.value.rowsNumber = totalCount.value
await loadPage(1, 20)
})
</script>

View File

@ -0,0 +1,156 @@
<!--
LoginPage passwordless portal login via magic link.
The only entry point for customers who don't have a ?token= in their URL.
Takes an email or phone, asks the hub to send a 24h magic link via SMS +
email, then shows a success state with redacted destinations so the user
knows where to look.
Failure modes surfaced to the user:
- Empty input inline field error
- Rate limit (429) banner with retry countdown
- Network error toast
- No match still shown as success (anti-enumeration). If sent array is
empty, we say "Lien envoyé" but no destinations matches the hub's
behaviour. This is intentional; legitimate users who typo their address
will see "no channels" and retry with the right one.
See docs/features/billing-payments.md for the full Stripe + magic-link
flow this page plugs into.
-->
<template>
<q-page class="flex flex-center" padding>
<div class="login-card">
<div class="text-center q-mb-lg">
<img src="https://store.targo.ca/clients/images/logo_trans_noir.png"
alt="Gigafibre" style="max-width: 180px" />
<div class="text-h5 text-weight-bold q-mt-md">Portail client</div>
<div class="text-body2 text-grey-7 q-mt-xs">
Entrez votre courriel ou téléphone pour recevoir un lien d'accès
</div>
</div>
<!-- Before send -->
<div v-if="!sent">
<q-input
v-model="identifier"
outlined
label="Courriel ou téléphone"
autofocus
:error="!!errorMsg"
:error-message="errorMsg"
:disable="loading"
@keyup.enter="submit"
>
<template #prepend>
<q-icon :name="isEmail ? 'email' : 'phone'" />
</template>
</q-input>
<q-btn
class="full-width q-mt-md"
color="primary"
unelevated
size="lg"
label="Recevoir mon lien"
icon="send"
:loading="loading"
:disable="!identifier.trim()"
@click="submit"
/>
<div class="text-caption text-grey-6 q-mt-md text-center">
Lien valide 24&nbsp;h. Pas de mot de passe requis.
</div>
</div>
<!-- After send -->
<div v-else class="text-center">
<q-icon name="mark_email_read" color="positive" size="64px" />
<div class="text-h6 q-mt-md">Lien envoyé&nbsp;!</div>
<div v-if="redacted.length" class="text-body2 text-grey-7 q-mt-sm">
Vérifiez&nbsp;:
<div class="q-mt-xs">
<span v-for="(r, i) in redacted" :key="i" class="q-mr-sm">
<q-chip dense outline color="grey-7" :label="r" />
</span>
</div>
</div>
<div v-else class="text-body2 text-grey-7 q-mt-sm">
Si votre compte existe, un lien vous a été envoyé.
Vérifiez vos SMS et courriels.
</div>
<q-btn
flat color="primary" class="q-mt-lg" label="Envoyer à nouveau"
icon="replay" @click="sent = false" no-caps
/>
</div>
<!-- Rate-limit banner -->
<q-banner v-if="rateLimited" rounded dense class="bg-orange-1 q-mt-md">
<template #avatar>
<q-icon name="schedule" color="warning" />
</template>
<div class="text-body2">{{ rateLimitMsg }}</div>
</q-banner>
</div>
</q-page>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useQuasar } from 'quasar'
import { requestPortalLink } from 'src/api/auth-portal'
const $q = useQuasar()
const identifier = ref('')
const loading = ref(false)
const sent = ref(false)
const redacted = ref([])
const errorMsg = ref('')
const rateLimited = ref(false)
const rateLimitMsg = ref('')
const isEmail = computed(() => identifier.value.includes('@'))
async function submit () {
const raw = identifier.value.trim()
if (!raw) {
errorMsg.value = 'Entrez un courriel ou un numéro de téléphone'
return
}
errorMsg.value = ''
rateLimited.value = false
loading.value = true
try {
const r = await requestPortalLink(raw)
redacted.value = r.redacted || []
sent.value = true
} catch (e) {
if (e.code === 'rate_limit') {
rateLimited.value = true
rateLimitMsg.value = e.message
} else {
$q.notify({
type: 'negative',
message: 'Erreur réseau. Réessayez dans un moment.',
timeout: 4000,
})
}
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-card {
background: #fff;
border-radius: 12px;
padding: 32px;
max-width: 440px;
width: 100%;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
</style>

View File

@ -0,0 +1,228 @@
<template>
<q-page padding>
<div class="page-title">Messages</div>
<!-- Filter tabs -->
<q-tabs v-model="activeTab" dense align="left" class="q-mb-md bg-white rounded-borders"
active-color="primary" indicator-color="primary" narrow-indicator>
<q-tab name="all" label="Tous" no-caps />
<q-tab name="SMS" label="SMS" no-caps icon="sms" />
<q-tab name="Email" label="Courriel" no-caps icon="email" />
<q-tab name="Phone" label="Appels" no-caps icon="phone" />
</q-tabs>
<!-- Loading -->
<div v-if="loading" class="flex flex-center q-pa-xl">
<q-spinner-dots size="48px" color="primary" />
</div>
<!-- Empty state -->
<div v-else-if="!filteredMessages.length" class="text-center text-grey-6 q-pa-xl">
<q-icon name="chat_bubble_outline" size="64px" class="q-mb-md" />
<div class="text-h6">Aucun message</div>
</div>
<!-- Grouped messages -->
<div v-else>
<div v-for="group in groupedMessages" :key="group.key" class="q-mb-lg">
<!-- Group header -->
<div class="flex items-center q-mb-sm">
<q-icon :name="groupIcon(group)" size="20px" color="grey-7" class="q-mr-sm" />
<div class="text-subtitle2 text-weight-medium">
{{ group.label }}
</div>
<q-badge v-if="group.ticketStatus" :color="ticketStatusColor(group.ticketStatus)"
:label="group.ticketStatus" class="q-ml-sm" />
<q-space />
<span class="text-caption text-grey-6">{{ group.dateRange }}</span>
</div>
<!-- Messages in group -->
<q-list bordered separator class="rounded-borders bg-white">
<q-item v-for="msg in group.messages" :key="msg.name" class="q-pa-sm">
<q-item-section avatar>
<q-icon :name="mediumIcon(msg.communication_medium)"
:color="msg.sent_or_received === 'Sent' ? 'primary' : 'grey-7'" />
</q-item-section>
<q-item-section>
<q-item-label class="flex items-center">
<q-icon v-if="msg.sent_or_received === 'Sent'" name="arrow_upward" size="14px" color="primary" class="q-mr-xs" />
<q-icon v-else name="arrow_downward" size="14px" color="positive" class="q-mr-xs" />
<span class="text-caption text-grey-7">
{{ msg.sent_or_received === 'Sent' ? 'Envoyé' : 'Reçu' }}
&middot; {{ formatDateTime(msg.creation) }}
</span>
</q-item-label>
<q-item-label class="q-mt-xs message-content" v-html="truncateHTML(msg.content, 200)" />
<q-item-label v-if="msg.phone_no" caption>
<q-icon name="phone" size="12px" /> {{ msg.phone_no }}
</q-item-label>
</q-item-section>
<q-item-section side v-if="msg.reference_doctype === 'Issue'">
<q-btn flat dense size="sm" color="primary" no-caps
:label="msg.reference_name"
@click="$router.push('/tickets/' + msg.reference_name)" />
</q-item-section>
</q-item>
</q-list>
</div>
</div>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { useCustomerStore } from 'src/stores/customer'
import { fetchAllCommunications } from 'src/api/portal'
import { useFormatters } from 'src/composables/useFormatters'
import { useSSE } from 'src/composables/useSSE'
const $q = useQuasar()
const store = useCustomerStore()
const { formatDate } = useFormatters()
const messages = ref([])
const loading = ref(true)
const activeTab = ref('all')
// SSE for real-time updates
const { connect: sseConnect } = useSSE({
onMessage: async (data) => {
if (data.customer === store.customerId) {
// Reload full list on any new message
messages.value = await fetchAllCommunications(store.customerId)
if (data.direction === 'in') {
$q.notify({
type: 'info',
icon: 'chat',
message: `Nouveau message de ${data.customer_name || data.phone || 'Client'}`,
timeout: 3000,
position: 'top-right',
})
}
}
},
})
function mediumIcon (m) {
if (m === 'SMS') return 'sms'
if (m === 'Phone') return 'phone'
if (m === 'Email') return 'email'
return 'chat_bubble_outline'
}
function groupIcon (group) {
if (group.type === 'ticket') return 'confirmation_number'
if (group.medium === 'SMS') return 'sms'
if (group.medium === 'Email') return 'email'
if (group.medium === 'Phone') return 'phone'
return 'chat_bubble_outline'
}
function ticketStatusColor (s) {
if (s === 'Open') return 'primary'
if (s === 'Closed' || s === 'Resolved') return 'positive'
return 'grey'
}
function formatDateTime (d) {
if (!d) return ''
return new Date(d).toLocaleString('fr-CA', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
function truncateHTML (html, maxLen) {
if (!html) return ''
// Strip HTML tags for length check, but return original if short enough
const text = html.replace(/<[^>]+>/g, '')
if (text.length <= maxLen) return html
return text.substring(0, maxLen) + '...'
}
const filteredMessages = computed(() => {
if (activeTab.value === 'all') return messages.value
return messages.value.filter(m => m.communication_medium === activeTab.value)
})
const groupedMessages = computed(() => {
const groups = new Map()
for (const msg of filteredMessages.value) {
let key, label, type, medium
if (msg.reference_doctype === 'Issue' && msg.reference_name) {
// Group by ticket
key = 'ticket:' + msg.reference_name
label = msg.reference_name + (msg.subject ? ' - ' + msg.subject : '')
type = 'ticket'
} else if (msg.communication_medium === 'SMS' && msg.phone_no) {
// Group SMS by phone number + date
const d = new Date(msg.creation).toISOString().slice(0, 10)
key = 'sms:' + msg.phone_no + ':' + d
label = 'SMS - ' + msg.phone_no
type = 'sms'
medium = 'SMS'
} else if (msg.communication_medium === 'Email' && msg.subject) {
// Group email by subject (strip Re: / Fwd: prefixes)
const cleanSubject = (msg.subject || '').replace(/^(Re|Fwd|TR|RE):\s*/gi, '').trim()
key = 'email:' + cleanSubject
label = msg.subject
type = 'email'
medium = 'Email'
} else {
// Group by date
const d = new Date(msg.creation).toISOString().slice(0, 10)
key = 'other:' + d
label = formatDate(msg.creation)
type = 'other'
medium = msg.communication_medium
}
if (!groups.has(key)) {
groups.set(key, { key, label, type, medium, messages: [], ticketStatus: null })
}
groups.get(key).messages.push(msg)
}
// Sort groups by latest message, compute dateRange
const result = Array.from(groups.values())
for (const g of result) {
g.messages.sort((a, b) => new Date(a.creation) - new Date(b.creation))
const first = g.messages[0].creation
const last = g.messages[g.messages.length - 1].creation
if (first === last) {
g.dateRange = formatDateTime(first)
} else {
g.dateRange = formatDateTime(first) + ' - ' + formatDateTime(last)
}
}
// Sort groups: most recent message first
result.sort((a, b) => {
const aLast = new Date(a.messages[a.messages.length - 1].creation)
const bLast = new Date(b.messages[b.messages.length - 1].creation)
return bLast - aLast
})
return result
})
onMounted(async () => {
if (!store.customerId) return
try {
messages.value = await fetchAllCommunications(store.customerId)
sseConnect(['customer:' + store.customerId])
} finally {
loading.value = false
}
})
</script>
<style scoped>
.message-content {
word-break: break-word;
}
.message-content :deep(p) {
margin: 0;
}
</style>

View File

@ -0,0 +1,171 @@
<template>
<q-page class="flex flex-center q-pa-md">
<div style="max-width:600px;width:100%">
<q-card flat bordered style="border-radius:16px" class="text-center q-pa-lg">
<!-- Success icon -->
<div class="q-mb-lg">
<div class="success-circle q-mx-auto">
<q-icon name="check" size="48px" color="white" />
</div>
</div>
<div class="text-h4 text-weight-bold q-mb-sm">Commande confirmée!</div>
<div class="text-subtitle1 text-grey-7 q-mb-lg">
Merci pour votre commande. Voici votre confirmation.
</div>
<!-- Order number -->
<q-card flat bordered class="q-pa-md q-mb-lg" style="border-radius:12px;background:#f5f7ff">
<div class="text-caption text-grey-6">Numéro de commande</div>
<div class="text-h5 text-weight-bold text-primary">{{ orderId }}</div>
</q-card>
<!-- Order summary -->
<div v-if="orderData" class="text-left q-mb-lg">
<div class="text-subtitle2 text-weight-bold q-mb-sm">Articles commandés</div>
<q-list dense separator>
<q-item v-for="item in orderData.items" :key="item.item_code">
<q-item-section>
<q-item-label>{{ item.item_name }}</q-item-label>
<q-item-label caption>
{{ item.billing_type === 'Mensuel' ? 'Mensuel' : 'Achat unique' }}
<span v-if="item.qty > 1"> x{{ item.qty }}</span>
</q-item-label>
</q-item-section>
<q-item-section side>
<span class="text-weight-medium">{{ formatPrice(item.rate * item.qty) }}</span>
</q-item-section>
</q-item>
</q-list>
<q-separator class="q-my-sm" />
<div class="row justify-between q-px-md">
<span class="text-weight-bold">Total</span>
<span class="text-weight-bold text-primary">
{{ formatPrice(orderData.totals.grand_total) }}
</span>
</div>
</div>
<!-- Installation notice -->
<q-banner
v-if="orderData && orderData.installation"
class="q-mb-lg text-left"
rounded
style="background:#e8f5e9"
>
<template #avatar>
<q-icon name="engineering" color="positive" />
</template>
<div class="text-weight-medium">Installation prévue</div>
<div class="text-body2 text-grey-8">
Date souhaitée : {{ formatDate(orderData.installation.preferred_date) }}
({{ orderData.installation.preferred_slot === 'AM' ? 'Matin' : 'Après-midi' }})
</div>
<div class="text-body2 text-grey-7 q-mt-xs">
Nous vous contacterons pour confirmer le rendez-vous d'installation.
</div>
</q-banner>
<!-- No installation -->
<q-banner
v-else
class="q-mb-lg text-left"
rounded
style="background:#e3f2fd"
>
<template #avatar>
<q-icon name="local_shipping" color="primary" />
</template>
<div class="text-body2">
Votre service sera activé dans les prochaines 24 heures. Vous recevrez un courriel de confirmation.
</div>
</q-banner>
<!-- Contact info -->
<q-card flat bordered class="q-pa-md q-mb-lg text-left" style="border-radius:12px">
<div class="text-subtitle2 text-weight-bold q-mb-sm">Besoin d'aide?</div>
<div class="text-body2 q-mb-xs">
<q-icon name="email" class="q-mr-xs" size="16px" />
support@gigafibre.ca
</div>
<div class="text-body2 q-mb-xs">
<q-icon name="phone" class="q-mr-xs" size="16px" />
1-888-GIGAFIBRE
</div>
<div class="text-body2">
<q-icon name="schedule" class="q-mr-xs" size="16px" />
Lundi - Vendredi, 8h - 20h
</div>
</q-card>
<!-- Actions -->
<div class="row q-gutter-sm justify-center">
<q-btn
color="primary"
unelevated
label="Retour au portail"
icon="home"
@click="$router.push('/')"
/>
<q-btn
outline
color="primary"
label="Voir le catalogue"
icon="storefront"
@click="$router.push('/catalogue')"
/>
</div>
</q-card>
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const orderId = ref(route.query.order || 'N/A')
const orderData = ref(null)
onMounted(() => {
// Try to recover order data from navigation state or sessionStorage
try {
const stateRaw = window.history.state?.orderData
if (stateRaw) {
orderData.value = JSON.parse(stateRaw)
}
} catch {
// Order data not available show minimal confirmation
}
})
function formatPrice (val) {
return (val || 0).toFixed(2) + ' $'
}
function formatDate (dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString('fr-CA', { year: 'numeric', month: 'long', day: 'numeric' })
}
</script>
<style lang="scss" scoped>
.success-circle {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, #43a047, #2e7d32);
display: flex;
align-items: center;
justify-content: center;
animation: pop-in 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes pop-in {
0% { transform: scale(0); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
</style>

View File

@ -0,0 +1,47 @@
<template>
<q-page padding class="flex flex-center">
<div style="max-width:520px;text-align:center">
<q-icon name="cancel" color="warning" size="80px" />
<div class="text-h5 text-weight-bold q-mt-lg">Paiement annule</div>
<div class="text-body1 text-grey-7 q-mt-sm">
Votre paiement n'a pas ete complete. Aucun montant n'a ete debite.
</div>
<div v-if="invoiceName" class="text-body2 text-grey-6 q-mt-sm">
Facture: <strong>{{ invoiceName }}</strong>
</div>
<!-- Authenticated -->
<div v-if="auth.authenticated" class="q-mt-xl q-gutter-sm">
<q-btn v-if="invoiceName" color="primary" unelevated label="Reessayer" icon="refresh"
@click="$router.push(`/invoices/${invoiceName}`)" />
<q-btn outline color="primary" label="Mes factures" icon="list" @click="$router.push('/invoices')" />
<q-btn flat color="grey-7" label="Accueil" icon="home" @click="$router.push('/')" />
</div>
<!-- Not authenticated -->
<div v-else class="q-mt-xl">
<q-banner rounded class="bg-blue-1 q-mb-md">
<template #avatar><q-icon name="info" color="primary" /></template>
<div class="text-body2">
Connectez-vous pour reessayer le paiement ou voir vos factures.
</div>
</q-banner>
<q-btn color="primary" unelevated label="Se connecter" icon="login" @click="goToLogin" />
</div>
</div>
</q-page>
</template>
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { useMagicToken } from 'src/composables/useMagicToken'
const route = useRoute()
const router = useRouter()
const auth = useMagicToken()
const invoiceName = route.query.invoice || ''
function goToLogin () {
router.push({ name: 'login' })
}
</script>

View File

@ -0,0 +1,41 @@
<template>
<q-page padding class="flex flex-center">
<div style="max-width:520px;text-align:center">
<q-icon name="credit_card" color="positive" size="80px" />
<div class="text-h5 text-weight-bold q-mt-lg">Carte ajoutee!</div>
<div class="text-body1 text-grey-7 q-mt-sm">
Votre carte de paiement a ete enregistree avec succes.
Vous pouvez maintenant activer le paiement automatique.
</div>
<!-- Authenticated -->
<div v-if="auth.authenticated" class="q-mt-xl q-gutter-sm">
<q-btn color="primary" unelevated label="Mon compte" icon="person" @click="$router.push('/me')" />
<q-btn flat color="grey-7" label="Accueil" icon="home" @click="$router.push('/')" />
</div>
<!-- Not authenticated -->
<div v-else class="q-mt-xl">
<q-banner rounded class="bg-blue-1 q-mb-md">
<template #avatar><q-icon name="info" color="primary" /></template>
<div class="text-body2">
Connectez-vous pour gerer vos cartes et activer le paiement automatique.
</div>
</q-banner>
<q-btn color="primary" unelevated label="Se connecter" icon="login" @click="goToLogin" />
</div>
</div>
</q-page>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useMagicToken } from 'src/composables/useMagicToken'
const auth = useMagicToken()
const router = useRouter()
function goToLogin () {
router.push({ name: 'login' })
}
</script>

View File

@ -0,0 +1,50 @@
<template>
<q-page padding class="flex flex-center">
<div style="max-width:520px;text-align:center">
<q-icon name="check_circle" color="positive" size="80px" />
<div class="text-h5 text-weight-bold q-mt-lg">Paiement recu!</div>
<div class="text-body1 text-grey-7 q-mt-sm">
Merci pour votre paiement. Votre facture sera mise a jour sous peu.
</div>
<div v-if="invoiceName" class="text-body2 text-grey-6 q-mt-sm">
Facture: <strong>{{ invoiceName }}</strong>
</div>
<!-- Authenticated: show full navigation -->
<div v-if="auth.authenticated" class="q-mt-xl q-gutter-sm">
<q-btn v-if="invoiceName" color="primary" unelevated label="Voir la facture" icon="receipt"
@click="$router.push(`/invoices/${invoiceName}`)" />
<q-btn outline color="primary" label="Mes factures" icon="list" @click="$router.push('/invoices')" />
<q-btn flat color="grey-7" label="Accueil" icon="home" @click="$router.push('/')" />
</div>
<!-- Expired token or no auth: show login prompt -->
<div v-else class="q-mt-xl">
<q-banner rounded class="bg-blue-1 q-mb-md">
<template #avatar><q-icon name="info" color="primary" /></template>
<div class="text-body2">
Pour acceder a votre compte et voir vos factures, connectez-vous ou demandez un lien d'acces.
</div>
</q-banner>
<div class="q-gutter-sm">
<q-btn color="primary" unelevated label="Recevoir mon lien d'acces" icon="login"
@click="goToLogin" />
</div>
</div>
</div>
</q-page>
</template>
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { useMagicToken } from 'src/composables/useMagicToken'
const route = useRoute()
const router = useRouter()
const auth = useMagicToken()
const invoiceName = route.query.invoice || ''
function goToLogin () {
router.push({ name: 'login' })
}
</script>

View File

@ -0,0 +1,304 @@
<template>
<q-page padding>
<!-- Header -->
<div class="flex items-center q-mb-md">
<q-btn flat round icon="arrow_back" @click="$router.push('/tickets')" />
<div class="q-ml-sm">
<div class="page-title q-mb-none">{{ ticket?.subject || ticketName }}</div>
<div v-if="ticket" class="text-caption text-grey-7">
{{ ticketName }} &middot; Créé le {{ formatDate(ticket.creation) }}
</div>
</div>
<q-space />
<q-badge v-if="ticket" :color="statusColor(ticket.status)" :label="statusLabel(ticket.status)" class="text-body2 q-pa-sm" />
</div>
<!-- Ticket info -->
<div v-if="ticket" class="row q-col-gutter-md q-mb-md">
<div class="col-12 col-md-8">
<!-- Description -->
<div class="portal-card">
<div class="text-subtitle2 text-weight-medium q-mb-sm">Description</div>
<div class="ticket-description" v-html="ticket.description || '<em>Aucune description</em>'" />
</div>
<!-- Conversation thread -->
<div class="portal-card q-mt-md">
<div class="text-subtitle2 text-weight-medium q-mb-md">Conversation</div>
<!-- Empty state -->
<div v-if="!thread.length && !loading" class="text-grey-6 text-center q-pa-lg">
Aucun message pour le moment
</div>
<!-- Thread entries -->
<div v-for="(group, gi) in groupedThread" :key="gi" class="q-mb-md">
<div class="text-center q-mb-sm">
<q-chip size="sm" color="grey-3" text-color="grey-7" dense>
{{ group.date }}
</q-chip>
</div>
<div v-for="entry in group.items" :key="entry.name" class="thread-entry q-mb-sm"
:class="entry.sent_or_received === 'Sent' && entry.sender === 'portal@gigafibre.ca' ? 'thread-mine' : 'thread-theirs'">
<div class="thread-bubble">
<div class="flex items-center q-mb-xs">
<q-icon :name="mediumIcon(entry.communication_medium)" size="16px" class="q-mr-xs" />
<span class="text-caption text-weight-medium">
{{ entry.sender_full_name || entry.sender || 'Système' }}
</span>
<q-space />
<span class="text-caption text-grey-6">{{ formatTime(entry.creation) }}</span>
</div>
<div class="thread-content" v-html="entry.content" />
</div>
</div>
</div>
<!-- Reply box -->
<div class="q-mt-md">
<q-input v-model="replyText" outlined placeholder="Répondre..." type="textarea" rows="3"
:disable="sending" autogrow />
<div class="flex justify-end q-mt-sm">
<q-btn color="primary" label="Envoyer" icon="send" no-caps
:loading="sending" :disable="!replyText.trim()"
@click="sendReply" />
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-12 col-md-4">
<div class="portal-card">
<div class="text-subtitle2 text-weight-medium q-mb-sm">Détails</div>
<q-list dense>
<q-item>
<q-item-section>
<q-item-label caption>Statut</q-item-label>
<q-item-label>
<q-badge :color="statusColor(ticket.status)" :label="statusLabel(ticket.status)" />
</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>Priorité</q-item-label>
<q-item-label>
<q-badge :color="priorityColor(ticket.priority)" :label="ticket.priority || 'Medium'" outline />
</q-item-label>
</q-item-section>
</q-item>
<q-item v-if="ticket.issue_type">
<q-item-section>
<q-item-label caption>Type</q-item-label>
<q-item-label>{{ ticket.issue_type }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>Créé le</q-item-label>
<q-item-label>{{ formatDate(ticket.creation) }}</q-item-label>
</q-item-section>
</q-item>
<q-item v-if="ticket.resolution_date">
<q-item-section>
<q-item-label caption>Résolu le</q-item-label>
<q-item-label>{{ formatDate(ticket.resolution_date) }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex flex-center q-pa-xl">
<q-spinner-dots size="48px" color="primary" />
</div>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { useCustomerStore } from 'src/stores/customer'
import { fetchTicket, fetchCommunications, fetchComments, replyToTicket } from 'src/api/portal'
import { useFormatters } from 'src/composables/useFormatters'
import { useSSE } from 'src/composables/useSSE'
const route = useRoute()
const router = useRouter()
const $q = useQuasar()
const store = useCustomerStore()
const { formatDate } = useFormatters()
const ticketName = route.params.name
const ticket = ref(null)
const thread = ref([])
const loading = ref(true)
const replyText = ref('')
const sending = ref(false)
// SSE for real-time updates
const { connect: sseConnect } = useSSE({
onMessage: async (data) => {
if (data.customer === store.customerId) {
await loadThread()
if (data.direction === 'in') {
$q.notify({
type: 'info',
icon: 'chat',
message: `Nouvelle réponse sur ${ticketName}`,
timeout: 3000,
position: 'top-right',
})
}
}
},
})
function statusColor (s) {
if (s === 'Open') return 'primary'
if (s === 'Closed' || s === 'Resolved') return 'positive'
if (s === 'Replied') return 'info'
return 'grey'
}
function statusLabel (s) {
const map = { Open: 'Ouvert', Closed: 'Fermé', Resolved: 'Résolu', Replied: 'Répondu' }
return map[s] || s
}
function priorityColor (p) {
if (p === 'High' || p === 'Urgent') return 'negative'
if (p === 'Medium') return 'warning'
return 'grey'
}
function mediumIcon (m) {
if (m === 'SMS') return 'sms'
if (m === 'Phone') return 'phone'
if (m === 'Email') return 'email'
return 'chat_bubble_outline'
}
function formatTime (d) {
if (!d) return ''
return new Date(d).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })
}
const groupedThread = computed(() => {
const groups = []
let currentDate = null
let currentGroup = null
for (const entry of thread.value) {
const d = new Date(entry.creation).toLocaleDateString('fr-CA', {
year: 'numeric', month: 'long', day: 'numeric',
})
if (d !== currentDate) {
currentDate = d
currentGroup = { date: d, items: [] }
groups.push(currentGroup)
}
currentGroup.items.push(entry)
}
return groups
})
async function loadThread () {
const [coms, comments] = await Promise.all([
fetchCommunications('Issue', ticketName),
fetchComments('Issue', ticketName),
])
// Merge communications and comments into unified thread sorted by creation
const merged = [
...coms.map(c => ({ ...c, _type: 'communication' })),
...comments.map(c => ({
name: c.name,
communication_medium: 'Other',
sent_or_received: 'Received',
sender: c.comment_by,
sender_full_name: c.comment_by,
content: c.content,
creation: c.creation,
_type: 'comment',
})),
]
merged.sort((a, b) => new Date(a.creation) - new Date(b.creation))
thread.value = merged
}
async function sendReply () {
if (!replyText.value.trim()) return
sending.value = true
try {
await replyToTicket(ticketName, replyText.value.trim())
replyText.value = ''
$q.notify({ type: 'positive', message: 'Message envoyé' })
await loadThread()
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
sending.value = false
}
}
onMounted(async () => {
if (!store.customerId) return
try {
const t = await fetchTicket(ticketName)
// Security: verify ticket belongs to customer
if (t.customer !== store.customerId) {
router.replace('/tickets')
return
}
ticket.value = t
await loadThread()
// Connect SSE for real-time updates on this customer
sseConnect(['customer:' + store.customerId])
} finally {
loading.value = false
}
})
</script>
<style scoped>
.ticket-description {
line-height: 1.6;
word-break: break-word;
}
.ticket-description :deep(img) {
max-width: 100%;
}
.thread-entry {
display: flex;
}
.thread-mine {
justify-content: flex-end;
}
.thread-theirs {
justify-content: flex-start;
}
.thread-bubble {
max-width: 80%;
padding: 10px 14px;
border-radius: 12px;
font-size: 14px;
}
.thread-mine .thread-bubble {
background: #e3f2fd;
border-bottom-right-radius: 4px;
}
.thread-theirs .thread-bubble {
background: #f5f5f5;
border-bottom-left-radius: 4px;
}
.thread-content {
word-break: break-word;
}
.thread-content :deep(p) {
margin: 0;
}
</style>

View File

@ -0,0 +1,132 @@
<template>
<q-page padding>
<div class="flex items-center justify-between q-mb-md">
<div class="page-title q-mb-none">Support</div>
<q-btn color="primary" icon="add" label="Nouveau ticket" no-caps @click="showCreate = true" />
</div>
<q-table
:rows="tickets"
:columns="columns"
row-key="name"
:loading="loading"
@row-click="(evt, row) => router.push('/tickets/' + row.name)"
flat bordered
class="bg-white clickable-table"
no-data-label="Aucun ticket"
:pagination="{ rowsPerPage: 50 }"
>
<template #body-cell-creation="props">
<q-td :props="props">{{ formatShortDate(props.value) }}</q-td>
</template>
<template #body-cell-status="props">
<q-td :props="props">
<q-badge :color="statusColor(props.value)" :label="statusLabel(props.value)" />
</q-td>
</template>
<template #body-cell-priority="props">
<q-td :props="props">
<q-badge :color="priorityColor(props.value)" :label="props.value" outline />
</q-td>
</template>
</q-table>
<!-- New ticket dialog -->
<q-dialog v-model="showCreate" persistent>
<q-card style="min-width: 400px; max-width: 600px">
<q-card-section>
<div class="text-h6">Nouveau ticket de support</div>
</q-card-section>
<q-card-section>
<q-input v-model="newTicket.subject" label="Sujet" outlined class="q-mb-md"
:rules="[v => !!v || 'Le sujet est requis']" />
<q-input v-model="newTicket.description" label="Description" outlined type="textarea"
rows="4" :rules="[v => !!v || 'La description est requise']" />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" @click="showCreate = false" />
<q-btn color="primary" label="Envoyer" :loading="creating"
@click="submitTicket" :disable="!newTicket.subject || !newTicket.description" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { useCustomerStore } from 'src/stores/customer'
import { fetchTickets, createTicket } from 'src/api/portal'
import { useFormatters } from 'src/composables/useFormatters'
const router = useRouter()
const $q = useQuasar()
const store = useCustomerStore()
const { formatShortDate } = useFormatters()
const tickets = ref([])
const loading = ref(false)
const showCreate = ref(false)
const creating = ref(false)
const newTicket = ref({ subject: '', description: '' })
const columns = [
{ name: 'name', label: 'No.', field: 'name', align: 'left' },
{ name: 'subject', label: 'Sujet', field: 'subject', align: 'left' },
{ name: 'status', label: 'Statut', field: 'status', align: 'center' },
{ name: 'priority', label: 'Priorité', field: 'priority', align: 'center' },
{ name: 'creation', label: 'Créé le', field: 'creation', align: 'left' },
]
function statusColor (s) {
if (s === 'Open') return 'primary'
if (s === 'Closed' || s === 'Resolved') return 'positive'
if (s === 'Replied') return 'info'
return 'grey'
}
function statusLabel (s) {
const map = { Open: 'Ouvert', Closed: 'Fermé', Resolved: 'Résolu', Replied: 'Répondu' }
return map[s] || s
}
function priorityColor (p) {
if (p === 'High' || p === 'Urgent') return 'negative'
if (p === 'Medium') return 'warning'
return 'grey'
}
async function loadTickets () {
loading.value = true
try {
tickets.value = await fetchTickets(store.customerId)
} finally {
loading.value = false
}
}
async function submitTicket () {
creating.value = true
try {
await createTicket(store.customerId, newTicket.value.subject, newTicket.value.description)
showCreate.value = false
newTicket.value = { subject: '', description: '' }
$q.notify({ type: 'positive', message: 'Ticket créé avec succès' })
await loadTickets()
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
creating.value = false
}
}
onMounted(() => {
if (store.customerId) loadTickets()
})
</script>

View File

@ -0,0 +1,84 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { useCustomerStore } from 'src/stores/customer'
/**
* Portal router.
*
* Two route trees:
* /login standalone (no drawer, no header) LoginPage.vue
* /* PortalLayout.vue (drawer + header + nav)
*
* Navigation guard: customers without a valid session get bounced to /login.
* A valid session is one of:
* - URL carries ?token=JWT (handled by useMagicToken on page mount)
* - customerStore.customerId is already set (previously authenticated)
*
* Routes marked in PUBLIC_ROUTES skip the guard these are payment-return
* landings where we want to show a success/cancel message even if the token
* expired, plus the catalog which is intentionally browsable unauthenticated.
*/
// Routes that work without a customer session — payment returns and public
// catalog. Must match the `name` value on the route, not the path.
const PUBLIC_ROUTES = new Set([
'login',
'catalog',
'cart',
'order-success',
'payment-success',
'payment-cancel',
'payment-card-added',
])
const routes = [
// Standalone — no layout wrapper
{ path: '/login', name: 'login', component: () => import('pages/LoginPage.vue') },
// Main portal — wrapped in drawer + header layout
{
path: '/',
component: () => import('layouts/PortalLayout.vue'),
children: [
{ path: '', name: 'dashboard', component: () => import('pages/DashboardPage.vue') },
{ path: 'invoices', name: 'invoices', component: () => import('pages/InvoicesPage.vue') },
{ path: 'invoices/:name', name: 'invoice-detail', component: () => import('pages/InvoiceDetailPage.vue') },
{ path: 'tickets', name: 'tickets', component: () => import('pages/TicketsPage.vue') },
{ path: 'tickets/:name', name: 'ticket-detail', component: () => import('pages/TicketDetailPage.vue') },
{ path: 'messages', name: 'messages', component: () => import('pages/MessagesPage.vue') },
{ path: 'me', name: 'account', component: () => import('pages/AccountPage.vue') },
{ path: 'catalogue', name: 'catalog', component: () => import('pages/CatalogPage.vue') },
{ path: 'panier', name: 'cart', component: () => import('pages/CartPage.vue') },
{ path: 'commande/confirmation', name: 'order-success', component: () => import('pages/OrderSuccessPage.vue') },
{ path: 'paiement/merci', name: 'payment-success', component: () => import('pages/PaymentSuccessPage.vue') },
{ path: 'paiement/annule', name: 'payment-cancel', component: () => import('pages/PaymentCancelPage.vue') },
{ path: 'paiement/carte-ajoutee', name: 'payment-card-added', component: () => import('pages/PaymentCardAddedPage.vue') },
],
},
]
const router = createRouter({
history: createWebHashHistory(process.env.VUE_ROUTER_BASE),
routes,
})
// Navigation guard: push unauthenticated users to /login (unless the route
// is public OR the URL carries a fresh ?token=).
router.beforeEach((to, from, next) => {
if (PUBLIC_ROUTES.has(to.name)) return next()
const store = useCustomerStore()
// Already authenticated from a previous nav in this session.
if (store.customerId) return next()
// URL carries a token? Hydrate synchronously (URL parse + base64 decode,
// no network). Covers magic-link clicks landing on any page and avoids
// the race between App.vue's async init() and the first navigation.
if (to.query.token || (typeof window !== 'undefined' && window.location.hash.includes('token='))) {
if (store.hydrateFromToken()) return next()
}
next({ name: 'login' })
})
export default router

View File

@ -0,0 +1,98 @@
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
const STORAGE_KEY = 'gigafibre_cart'
function loadFromStorage () {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : []
} catch { return [] }
}
export const useCartStore = defineStore('cart', () => {
const items = ref(loadFromStorage())
const taxRate = 0.14975 // TPS 5% + TVQ 9.975%
// Persist to localStorage on every change
watch(items, (val) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(val))
}, { deep: true })
function addItem (product) {
const existing = items.value.find(i => i.item_code === product.item_code)
if (existing) {
existing.qty += 1
} else {
items.value.push({
item_code: product.item_code,
item_name: product.item_name,
rate: product.rate,
qty: 1,
billing_type: product.billing_type,
service_category: product.service_category,
requires_visit: product.requires_visit || false,
project_template_id: product.project_template_id || null,
description: product.description || '',
})
}
}
function removeItem (index) {
items.value.splice(index, 1)
}
function updateQty (index, qty) {
if (qty < 1) {
removeItem(index)
return
}
items.value[index].qty = qty
}
function clearCart () {
items.value = []
}
const itemCount = computed(() =>
items.value.reduce((sum, i) => sum + i.qty, 0),
)
const onetimeTotal = computed(() =>
items.value
.filter(i => i.billing_type !== 'Mensuel')
.reduce((sum, i) => sum + i.rate * i.qty, 0),
)
const recurringTotal = computed(() =>
items.value
.filter(i => i.billing_type === 'Mensuel')
.reduce((sum, i) => sum + i.rate * i.qty, 0),
)
const subtotal = computed(() => onetimeTotal.value + recurringTotal.value)
const taxAmount = computed(() => subtotal.value * taxRate)
const grandTotal = computed(() => subtotal.value + taxAmount.value)
const requiresVisit = computed(() =>
items.value.some(i => i.requires_visit),
)
return {
items,
taxRate,
addItem,
removeItem,
updateQty,
clearCart,
itemCount,
onetimeTotal,
recurringTotal,
subtotal,
taxAmount,
grandTotal,
requiresVisit,
}
})

View File

@ -0,0 +1,109 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getPortalUser } from 'src/api/portal'
/**
* Customer session store.
*
* Two hydration paths:
* 1. Magic-link JWT in the URL (`?token=JWT`). Decoded client-side the
* hub already signed it and the user received it through their own
* verified channel (SMS/email), so trust is established. No network
* call needed.
* 2. ERPNext session (legacy / staff impersonation). Only tried when
* there's no token AND no existing store state. Failure is silent
* the router guard will bounce the user to /login.
*
* For the token path we also strip `?token=` from the URL so a page refresh
* or bookmark doesn't replay the token forever. The store state carries us
* for the rest of the session; if the user refreshes without a token, the
* guard sees `customerId` still set and lets them through.
*/
function readTokenFromLocation () {
if (typeof window === 'undefined') return ''
const hash = window.location.hash || ''
// Hash form: #/paiement/merci?token=xxx OR #/?token=xxx
const qIdx = hash.indexOf('?')
if (qIdx === -1) return ''
const qs = new URLSearchParams(hash.slice(qIdx + 1))
return qs.get('token') || ''
}
function decodeJwtPayload (token) {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')))
if (payload.exp && Date.now() / 1000 > payload.exp) return null
return payload
} catch {
return null
}
}
function stripTokenFromUrl () {
if (typeof window === 'undefined') return
const hash = window.location.hash || ''
const qIdx = hash.indexOf('?')
if (qIdx === -1) return
const before = hash.slice(0, qIdx)
const params = new URLSearchParams(hash.slice(qIdx + 1))
params.delete('token')
const rest = params.toString()
const newHash = rest ? `${before}?${rest}` : before
// Use replaceState so the token doesn't stay in browser history
window.history.replaceState(null, '', window.location.pathname + window.location.search + newHash)
}
export const useCustomerStore = defineStore('customer', () => {
const email = ref('')
const customerId = ref('')
const customerName = ref('')
const loading = ref(true)
const error = ref(null)
function hydrateFromToken () {
const token = readTokenFromLocation()
if (!token) return false
const payload = decodeJwtPayload(token)
if (!payload || payload.scope !== 'customer') return false
customerId.value = payload.sub
customerName.value = payload.name || payload.sub
email.value = payload.email || ''
stripTokenFromUrl()
return true
}
async function init () {
loading.value = true
error.value = null
try {
// Path 1: magic-link token in URL
if (hydrateFromToken()) return
// Already authenticated in this tab? (e.g. subsequent nav)
if (customerId.value) return
// Path 2: fall back to ERPNext session (staff impersonation / legacy)
const user = await getPortalUser()
email.value = user.email
customerId.value = user.customer_id
customerName.value = user.customer_name
} catch (e) {
// Silent — router guard will push to /login when customerId is empty
error.value = null
} finally {
loading.value = false
}
}
function clear () {
email.value = ''
customerId.value = ''
customerName.value = ''
error.value = null
}
return { email, customerId, customerName, loading, error, init, hydrateFromToken, clear }
})

View File

@ -0,0 +1,15 @@
export const formatPrice = (val) => val.toFixed(2) + ' $'
// Strip +1/1 prefix from E.164 phone → 10-digit local format for mask
export function normalizePhone (raw) {
if (!raw) return ''
const digits = raw.replace(/\D/g, '')
return digits.length === 11 && digits.startsWith('1') ? digits.slice(1) : digits
}
// Minimum booking date (J+3)
export function minBookingDate () {
const d = new Date()
d.setDate(d.getDate() + 3)
return d.toISOString().slice(0, 10)
}

17
apps/ops/.env.example Normal file
View File

@ -0,0 +1,17 @@
# ─────────────────────────────────────────────────────────────────────────
# apps/ops — Vite/Quasar dev environment
# Copy to `apps/ops/.env`, fill in the values, then `npx quasar dev`.
# Vite only exposes vars prefixed with VITE_ to the browser bundle.
# ─────────────────────────────────────────────────────────────────────────
# ERPNext API token used by the SPA when calling /api/resource/*.
# Format: <api_key>:<api_secret>. Mint one in ERPNext Desk under
# User → API Access. Needs roles for the doctypes the page touches
# (Customer, Sales Invoice, Dispatch Job, Service Subscription, etc.).
VITE_ERP_TOKEN=changeme_apikey:changeme_apisecret
# Public URL of the targo-hub (Node/Express). The SPA hits this for
# dispatch routes, traccar passthrough, conversations, voice agent,
# /auth/users, etc. Must be HTTPS in production.
# Local dev: http://localhost:3300
VITE_HUB_URL=https://msg.gigafibre.ca

56
apps/ops/.quasar/app.js Normal file
View File

@ -0,0 +1,56 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config.js > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { Quasar } from 'quasar'
import { markRaw } from 'vue'
import RootComponent from 'app/src/App.vue'
import createRouter from 'app/src/router/index'
export default async function (createAppFn, quasarUserOptions) {
// Create the app instance.
// Here we inject into it the Quasar UI, the router & possibly the store.
const app = createAppFn(RootComponent)
app.use(Quasar, quasarUserOptions)
const router = markRaw(
typeof createRouter === 'function'
? await createRouter({})
: createRouter
)
// Expose the app, the router and the store.
// Note that we are not mounting the app here, since bootstrapping will be
// different depending on whether we are in a browser or on the server.
return {
app,
router
}
}

View File

@ -0,0 +1 @@
{"folders":["/Users/louispaul/Documents/testap/gigafibre-fsm/apps/ops/dist/spa","/Users/louispaul/Documents/testap/gigafibre-fsm/apps/ops/dist/pwa"]}

View File

@ -0,0 +1,160 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config.js > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { createApp } from 'vue'
import '@quasar/extras/material-icons/material-icons.css'
// We load Quasar stylesheet file
import 'quasar/dist/quasar.css'
import 'src/css/app.scss'
import 'src/css/tech.scss'
import createQuasarApp from './app.js'
import quasarUserOptions from './quasar-user-options.js'
const publicPath = `/`
async function start ({
app,
router
}, bootFiles) {
let hasRedirected = false
const getRedirectUrl = url => {
try { return router.resolve(url).href }
catch (err) {}
return Object(url) === url
? null
: url
}
const redirect = url => {
hasRedirected = true
if (typeof url === 'string' && /^https?:\/\//.test(url)) {
window.location.href = url
return
}
const href = getRedirectUrl(url)
// continue if we didn't fail to resolve the url
if (href !== null) {
window.location.href = href
window.location.reload()
}
}
const urlPath = window.location.href.replace(window.location.origin, '')
for (let i = 0; hasRedirected === false && i < bootFiles.length; i++) {
try {
await bootFiles[i]({
app,
router,
ssrContext: null,
redirect,
urlPath,
publicPath
})
}
catch (err) {
if (err && err.url) {
redirect(err.url)
return
}
console.error('[Quasar] boot error:', err)
return
}
}
if (hasRedirected === true) {
return
}
app.use(router)
app.mount('#q-app')
}
createQuasarApp(createApp, quasarUserOptions)
.then(app => {
// eventually remove this when Cordova/Capacitor/Electron support becomes old
const [ method, mapFn ] = Promise.allSettled !== void 0
? [
'allSettled',
bootFiles => bootFiles.map(result => {
if (result.status === 'rejected') {
console.error('[Quasar] boot error:', result.reason)
return
}
return result.value.default
})
]
: [
'all',
bootFiles => bootFiles.map(entry => entry.default)
]
return Promise[ method ]([
import('boot/pinia')
]).then(bootFiles => {
const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function')
start(app, boot)
})
})

View File

@ -0,0 +1,116 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config.js > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import App from 'app/src/App.vue'
let appPrefetch = typeof App.preFetch === 'function'
? App.preFetch
: (
// Class components return the component options (and the preFetch hook) inside __c property
App.__c !== void 0 && typeof App.__c.preFetch === 'function'
? App.__c.preFetch
: false
)
function getMatchedComponents (to, router) {
const route = to
? (to.matched ? to : router.resolve(to).route)
: router.currentRoute.value
if (!route) { return [] }
const matched = route.matched.filter(m => m.components !== void 0)
if (matched.length === 0) { return [] }
return Array.prototype.concat.apply([], matched.map(m => {
return Object.keys(m.components).map(key => {
const comp = m.components[key]
return {
path: m.path,
c: comp
}
})
}))
}
export function addPreFetchHooks ({ router, publicPath }) {
// Add router hook for handling preFetch.
// Doing it after initial route is resolved so that we don't double-fetch
// the data that we already have. Using router.beforeResolve() so that all
// async components are resolved.
router.beforeResolve((to, from, next) => {
const
urlPath = window.location.href.replace(window.location.origin, ''),
matched = getMatchedComponents(to, router),
prevMatched = getMatchedComponents(from, router)
let diffed = false
const preFetchList = matched
.filter((m, i) => {
return diffed || (diffed = (
!prevMatched[i] ||
prevMatched[i].c !== m.c ||
m.path.indexOf('/:') > -1 // does it has params?
))
})
.filter(m => m.c !== void 0 && (
typeof m.c.preFetch === 'function'
// Class components return the component options (and the preFetch hook) inside __c property
|| (m.c.__c !== void 0 && typeof m.c.__c.preFetch === 'function')
))
.map(m => m.c.__c !== void 0 ? m.c.__c.preFetch : m.c.preFetch)
if (appPrefetch !== false) {
preFetchList.unshift(appPrefetch)
appPrefetch = false
}
if (preFetchList.length === 0) {
return next()
}
let hasRedirected = false
const redirect = url => {
hasRedirected = true
next(url)
}
const proceed = () => {
if (hasRedirected === false) { next() }
}
preFetchList.reduce(
(promise, preFetch) => promise.then(() => hasRedirected === false && preFetch({
currentRoute: to,
previousRoute: from,
redirect,
urlPath,
publicPath
})),
Promise.resolve()
)
.then(proceed)
.catch(e => {
console.error(e)
proceed()
})
})
}

View File

@ -0,0 +1,21 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config.js > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import {Notify,Loading,LocalStorage,Dialog} from 'quasar'
export default { config: {},plugins: {Notify,Loading,LocalStorage,Dialog} }

67
apps/ops/deploy.sh Executable file
View File

@ -0,0 +1,67 @@
#!/bin/bash
# ─────────────────────────────────────────────────────────────────────────────
# deploy.sh — Build Targo Ops PWA and deploy to ops-frontend nginx container
#
# The ops app is served by a standalone nginx container (ops-frontend) at
# erp.gigafibre.ca/ops/. Traefik strips /ops prefix before proxying to nginx.
# Authentik protection is handled via Traefik forwardAuth middleware.
#
# Static files go to /opt/ops-app/ on the host, mounted into the container.
#
# Usage:
# ./deploy.sh # deploy to remote server (SPA mode, no service worker cache)
# ./deploy.sh local # deploy to local Docker (development)
# ./deploy.sh pwa # deploy to remote server (PWA mode, for production)
#
# Prerequisites (remote):
# - SSH key ~/.ssh/proxmox_vm for root@96.125.196.67
# - ops-frontend container running (see infra/docker-compose.yaml)
# ─────────────────────────────────────────────────────────────────────────────
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
SERVER="root@96.125.196.67"
SSH_KEY="$HOME/.ssh/proxmox_vm"
DEST="/opt/ops-app"
# Default to SPA mode (no service worker = no cache headaches during dev)
BUILD_MODE="spa"
DIST_DIR="dist/spa"
if [ "$1" = "pwa" ]; then
BUILD_MODE="pwa"
DIST_DIR="dist/pwa"
shift
fi
echo "==> Installing dependencies..."
npm ci --silent
echo "==> Building $BUILD_MODE (base=/ops/)..."
DEPLOY_BASE=/ops/ npx quasar build -m "$BUILD_MODE"
if [ "$1" = "local" ]; then
# ── Local deploy ──
echo "==> Deploying to local $DEST..."
rm -rf "$DEST"/*
cp -r "$DIST_DIR"/* "$DEST/"
echo ""
echo "Done! Targo Ops: http://localhost/ops/"
else
# ── Remote deploy ──
echo "==> Packaging..."
tar czf /tmp/ops-build.tar.gz -C "$DIST_DIR" .
echo "==> Deploying to $SERVER..."
cat /tmp/ops-build.tar.gz | ssh -i "$SSH_KEY" "$SERVER" \
"cat > /tmp/ops.tar.gz && \
rm -rf $DEST/*.js $DEST/*.html $DEST/*.json $DEST/assets $DEST/icons $DEST/sw.js $DEST/workbox-*.js && \
cd $DEST && tar xzf /tmp/ops.tar.gz && \
rm -f /tmp/ops.tar.gz"
rm -f /tmp/ops-build.tar.gz
echo ""
echo "Done! Targo Ops ($BUILD_MODE): https://erp.gigafibre.ca/ops/"
fi

19
apps/ops/index.html Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<title>Targo Ops</title>
<meta charset="utf-8">
<meta name="description" content="Targo Operations Platform">
<meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="no">
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png">
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
<link rel="icon" type="image/ico" href="favicon.ico">
</head>
<body>
<!-- quasar:entry-point -->
</body>
</html>

View File

@ -0,0 +1,47 @@
# Targo Ops — nginx container served at erp.gigafibre.ca/ops/
# Deploy: docker compose -f docker-compose.yaml up -d
#
# Requires:
# - Traefik proxy network
# - Authentik forwardAuth middleware in Traefik
#
# Routing:
# erp.gigafibre.ca/ops/* → Traefik → StripPrefix /ops → nginx (static SPA)
# erp.gigafibre.ca/api/* → ERPNext directly (same domain, no proxy needed)
services:
ops-frontend:
image: nginx:alpine
container_name: ops-frontend
restart: unless-stopped
volumes:
- /opt/ops-app:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- proxy
labels:
- "traefik.enable=true"
# Main router: erp.gigafibre.ca/ops/* with Authentik + StripPrefix
- "traefik.http.routers.ops.rule=Host(`erp.gigafibre.ca`) && PathPrefix(`/ops`)"
- "traefik.http.routers.ops.entrypoints=web,websecure"
- "traefik.http.routers.ops.middlewares=authentik-client@file,ops-strip@docker"
- "traefik.http.routers.ops.service=ops"
- "traefik.http.routers.ops.tls.certresolver=letsencrypt"
- "traefik.http.routers.ops.priority=200"
# StripPrefix middleware (removes /ops before sending to nginx)
- "traefik.http.middlewares.ops-strip.stripprefix.prefixes=/ops"
- "traefik.http.middlewares.ops-strip.stripprefix.forceSlash=false"
# Authentik outpost callback (required for login redirect)
- "traefik.http.routers.ops-ak.rule=Host(`erp.gigafibre.ca`) && PathPrefix(`/outpost.goauthentik.io/`)"
- "traefik.http.routers.ops-ak.entrypoints=web,websecure"
- "traefik.http.routers.ops-ak.middlewares=authentik-client@file"
- "traefik.http.routers.ops-ak.service=ops"
- "traefik.http.routers.ops-ak.tls.certresolver=letsencrypt"
- "traefik.http.routers.ops-ak.priority=250"
# Service
- "traefik.http.services.ops.loadbalancer.server.port=80"
- "traefik.docker.network=proxy"
networks:
proxy:
external: true

View File

@ -0,0 +1,102 @@
#!/bin/bash
# ─────────────────────────────────────────────────────────────────────────────
# migrate-to-erpnext.sh — One-time migration: serve Ops from ERPNext container
#
# Run on the server (96.125.196.67) as root.
# This script:
# 1. Stops and removes the standalone ops-frontend container
# 2. Patches ERPNext docker-compose to add Authentik Traefik labels
# 3. Recreates ERPNext frontend with new labels
#
# After running this, use deploy.sh to push new builds.
# ─────────────────────────────────────────────────────────────────────────────
set -e
# Find ERPNext compose file
ERP_COMPOSE=""
for p in /opt/erpnext/docker-compose.yml /opt/frappe_docker/docker-compose.yml; do
[ -f "$p" ] && ERP_COMPOSE="$p" && break
done
[ -z "$ERP_COMPOSE" ] && echo "ERROR: ERPNext docker-compose not found" && exit 1
ERP_DIR="$(dirname "$ERP_COMPOSE")"
echo "=== Targo Ops Infrastructure Simplification ==="
echo "Using: $ERP_COMPOSE"
echo ""
# ── Step 1: Stop ops-frontend container ──
echo "==> Step 1: Stopping ops-frontend container..."
if docker ps -a --format '{{.Names}}' | grep -q 'ops-frontend'; then
(cd /opt/ops-app/infra 2>/dev/null && docker compose down 2>/dev/null) || \
(docker stop ops-frontend 2>/dev/null; docker rm ops-frontend 2>/dev/null) || true
echo " Done."
else
echo " Already removed."
fi
# ── Step 2: Patch docker-compose.yml ──
echo ""
echo "==> Step 2: Adding Authentik labels to ERPNext frontend..."
if grep -q 'routers.ops-app' "$ERP_COMPOSE"; then
echo " Already present. Skipping."
else
cp "$ERP_COMPOSE" "${ERP_COMPOSE}.bak"
python3 <<PYEOF
import re
compose_path = "$ERP_COMPOSE"
with open(compose_path) as f:
content = f.read()
# Labels to inject
ops_labels = ''' # ── Ops SPA (Authentik-protected) ──
- "traefik.http.routers.ops-app.rule=Host(\`erp.gigafibre.ca\`) && PathPrefix(\`/assets/ops-app\`)"
- "traefik.http.routers.ops-app.entrypoints=web,websecure"
- "traefik.http.routers.ops-app.middlewares=authentik@file"
- "traefik.http.routers.ops-app.service=erp"
- "traefik.http.routers.ops-app.tls.certresolver=letsencrypt"
- "traefik.http.routers.ops-app.priority=200"
# ── Authentik outpost callback ──
- "traefik.http.routers.ops-ak.rule=Host(\`erp.gigafibre.ca\`) && PathPrefix(\`/outpost.goauthentik.io/\`)"
- "traefik.http.routers.ops-ak.entrypoints=web,websecure"
- "traefik.http.routers.ops-ak.middlewares=authentik@file"
- "traefik.http.routers.ops-ak.service=erp"
- "traefik.http.routers.ops-ak.tls.certresolver=letsencrypt"
- "traefik.http.routers.ops-ak.priority=250"'''
# Find the last traefik label line and insert after it
lines = content.split('\\n')
last_traefik_idx = -1
for i, line in enumerate(lines):
if 'traefik.' in line and line.strip().startswith('-'):
last_traefik_idx = i
if last_traefik_idx == -1:
print("ERROR: No traefik labels found in compose file")
exit(1)
lines.insert(last_traefik_idx + 1, ops_labels)
with open(compose_path, 'w') as f:
f.write('\\n'.join(lines))
print(f" Injected after line {last_traefik_idx + 1}. Backup: {compose_path}.bak")
PYEOF
fi
# ── Step 3: Recreate ERPNext frontend ──
echo ""
echo "==> Step 3: Recreating ERPNext frontend..."
cd "$ERP_DIR"
docker compose up -d frontend
echo " Done."
echo ""
echo "=== Migration complete ==="
echo ""
echo "Deploy ops build: ./deploy.sh"
echo "Access: https://erp.gigafibre.ca/assets/ops-app/"
echo "Cleanup: rm -rf /opt/ops-app"
echo ""

45
apps/ops/infra/nginx.conf Normal file
View File

@ -0,0 +1,45 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
resolver 127.0.0.11 valid=30s;
# ERPNext API proxy token injected server-side (never in JS bundle)
# To rotate: edit this file + docker restart ops-frontend
location /api/ {
proxy_pass https://erp.gigafibre.ca;
proxy_ssl_verify off;
proxy_set_header Host erp.gigafibre.ca;
proxy_set_header Authorization "token b273a666c86d2d0:06120709db5e414";
proxy_set_header X-Authentik-Email $http_x_authentik_email;
proxy_set_header X-Authentik-Username $http_x_authentik_username;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
# NOTE: Ollama Vision proxy removed 2026-04-22 invoice OCR and all
# barcode/equipment scans now go directly to targo-hub (Gemini 2.5 Flash).
# See docs/features/vision-ocr.md. The hub handles CORS + rate-limit, so no
# nginx pass-through is needed here.
# SPA fallback all routes serve index.html
location / {
try_files $uri $uri/ /index.html;
}
# No cache for index.html and service worker
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Cache static assets (30 days, immutable)
location /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}

10278
apps/ops/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
apps/ops/package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "ops-app",
"version": "0.1.0",
"description": "Targo Ops — Unified ISP operations platform",
"productName": "Targo Ops",
"private": true,
"scripts": {
"dev": "quasar dev",
"build": "quasar build -m pwa",
"lint": "eslint --ext .js,.vue ./src"
},
"dependencies": {
"@quasar/extras": "^1.16.12",
"@twilio/voice-sdk": "^2.18.1",
"chart.js": "^4.5.1",
"cytoscape": "^3.33.2",
"grapesjs": "^0.22.16",
"grapesjs-mjml": "^1.0.8",
"grapesjs-preset-newsletter": "^1.0.2",
"idb-keyval": "^6.2.1",
"lucide-vue-next": "^1.0.0",
"pinia": "^2.1.7",
"quasar": "^2.16.10",
"sip.js": "^0.21.2",
"vue": "^3.4.21",
"vue-chartjs": "^5.3.3",
"vue-email-editor": "^2.2.0",
"vue-router": "^4.3.0",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@quasar/app-vite": "^1.10.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.24.0",
"sass": "^1.72.0",
"workbox-build": "7.0.x",
"workbox-cacheable-response": "7.0.x",
"workbox-core": "7.0.x",
"workbox-expiration": "7.0.x",
"workbox-precaching": "7.0.x",
"workbox-routing": "7.0.x",
"workbox-strategies": "7.0.x"
},
"engines": {
"node": "^18 || ^20",
"npm": ">= 6.13.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.5 KiB

71
apps/ops/quasar.config.js Normal file
View File

@ -0,0 +1,71 @@
/* eslint-env node */
const { configure } = require('quasar/wrappers')
module.exports = configure(function () {
return {
boot: ['pinia'],
css: ['app.scss', 'tech.scss'],
extras: ['material-icons'],
build: {
target: {
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
node: 'node20',
},
vueRouterMode: 'hash',
extendViteConf (viteConf) {
viteConf.base = process.env.DEPLOY_BASE || '/ops/'
},
},
devServer: {
open: false,
host: '0.0.0.0',
port: 9001,
proxy: {
'/ops/api': {
target: 'https://erp.gigafibre.ca',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ops/, ''),
},
'/api': {
target: 'https://erp.gigafibre.ca',
changeOrigin: true,
},
},
},
framework: {
config: {},
plugins: ['Notify', 'Loading', 'LocalStorage', 'Dialog'],
},
animations: [],
pwa: {
workboxMode: 'generateSW',
injectPwaMetaTags: true,
swFilename: 'sw.js',
manifestFilename: 'manifest.json',
useCredentialForManifestTag: false,
workboxOptions: {
skipWaiting: true,
clientsClaim: true,
cleanupOutdatedCaches: true,
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api\//],
},
extendManifestJson (json) {
json.name = 'Targo Ops'
json.short_name = 'Ops'
json.description = 'Targo Operations Platform'
json.display = 'standalone'
json.background_color = '#ffffff'
json.theme_color = '#1e293b'
json.start_url = '.'
},
},
}
})

View File

@ -0,0 +1,26 @@
/* eslint-env serviceworker */
import { clientsClaim } from 'workbox-core'
import { precacheAndRoute, cleanupOutdatedCaches, createHandlerBoundToURL } from 'workbox-precaching'
import { registerRoute, NavigationRoute } from 'workbox-routing'
self.skipWaiting()
clientsClaim()
// Listen for skip waiting message from register-service-worker
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
})
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
if (process.env.MODE !== 'ssr' || process.env.PROD) {
registerRoute(
new NavigationRoute(
createHandlerBoundToURL(process.env.PWA_FALLBACK_HTML),
{ denylist: [/sw\.js$/, /workbox-(.)*\.js$/] }
)
)
}

View File

@ -0,0 +1,32 @@
{
"orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#1e293b",
"icons": [
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/icon-256x256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

10
apps/ops/src-pwa/pwa-flag.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/* eslint-disable */
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
import "quasar/dist/types/feature-flag";
declare module "quasar/dist/types/feature-flag" {
interface QuasarFeatureFlags {
pwa: true;
}
}

View File

@ -0,0 +1,19 @@
import { register } from 'register-service-worker'
register(process.env.SERVICE_WORKER_FILE, {
ready () {},
registered (reg) {
// Check for updates every 5 minutes
setInterval(() => { reg.update() }, 5 * 60 * 1000)
},
cached () {},
updatefound () {},
updated (reg) {
// New service worker available — activate it silently (no reload)
if (reg && reg.waiting) {
reg.waiting.postMessage({ type: 'SKIP_WAITING' })
}
},
offline () {},
error () {}
})

11
apps/ops/src/App.vue Normal file
View File

@ -0,0 +1,11 @@
<template>
<router-view />
</template>
<script setup>
import { onMounted } from 'vue'
import { useAuthStore } from 'src/stores/auth'
const auth = useAuthStore()
onMounted(() => auth.checkSession())
</script>

View File

@ -0,0 +1,29 @@
/**
* API Conformité des adresses appelle targo-hub /address/conformity/*.
* Source de vérité : Service Location (lien AQ local rqa_addresses). Voir services/targo-hub/lib/address-conformity.js.
*/
import { HUB_URL as HUB } from 'src/config/hub'
async function jget (path) {
const r = await fetch(HUB + path)
if (!r.ok) throw new Error('Address API ' + r.status)
return r.json()
}
async function jpost (path, body) {
const r = await fetch(HUB + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })
if (!r.ok) throw new Error('Address API ' + r.status)
return r.json()
}
export const conformityStats = () => jget('/address/conformity/stats')
export const conformityList = (p) => jget('/address/conformity/list?' + new URLSearchParams(p).toString())
export const conformityCandidates = (q) => jget('/address/conformity/candidates?q=' + encodeURIComponent(q))
// action: approve | correct | gps | reject (+ aq_address_id/linked_address/latitude/longitude selon l'action)
export const conformityResolve = (body) => jpost('/address/conformity/resolve', body)
// ── Registre des campings (géoloc de remplacement fixe par camping) ──
export const campingsList = () => jget('/address/conformity/campings')
export const campingsUpsert = (body) => jpost('/address/conformity/campings', body) // {keyword,name,address,latitude,longitude}
export const campingsApply = () => jpost('/address/conformity/campings/apply', {})
// Dernier repli : placer les unmatched restants au centre du code postal (sinon ville) → statut 'area'
export const conformityApplyArea = () => jpost('/address/conformity/apply-area', {})

68
apps/ops/src/api/auth.js Normal file
View File

@ -0,0 +1,68 @@
import { BASE_URL } from 'src/config/erpnext'
// Token is optional — in production, nginx injects it server-side.
// Only needed for local dev (VITE_ERP_TOKEN in .env).
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || ''
// Reconnexion auto sur session Authentik expirée, anti-boucle (1 reload / 20 s max).
let _lastReload = 0
function _reauth (status) {
const now = Date.now()
if (now - _lastReload > 20000) {
_lastReload = now
window.location.reload()
}
return new Response('{}', { status: status || 401 })
}
export async function authFetch (url, opts = {}) {
opts.headers = SERVICE_TOKEN
? { ...opts.headers, Authorization: 'token ' + SERVICE_TOKEN }
: { ...opts.headers }
let res
try {
res = await fetch(url, opts)
} catch (e) {
// Coupure réseau transitoire (ex: backend qui redémarre) → 1 retry court avant d'abandonner
await new Promise(r => setTimeout(r, 800))
res = await fetch(url, opts)
}
// Détection « session Authentik expirée » (la cause du "il faut recharger") :
// - 401/403 explicites
// - réponse redirigée vers l'IdP (auth.targo.ca / /if/flow/)
// - page HTML de login renvoyée à la place du JSON attendu sur un /api/
const ct = (res.headers && res.headers.get && res.headers.get('content-type')) || ''
const redirectedToIdp = res.redirected && /auth\.targo\.ca|\/if\/flow\//.test(res.url || '')
const htmlForApi = res.status === 200 && ct.includes('text/html') && /\/api\//.test(String(url))
if (res.status === 401 || res.status === 403 || redirectedToIdp || htmlForApi) {
return _reauth(res.status)
}
return res
}
export async function getLoggedUser () {
// First try nginx whoami endpoint (returns Authentik email header)
try {
const res = await fetch(BASE_URL + '/auth/whoami')
if (res.ok) {
const data = await res.json()
if (data.email && data.email !== '') return data.email
}
} catch {}
// Fallback: ask ERPNext (returns API token owner, usually "Administrator")
try {
const headers = SERVICE_TOKEN ? { Authorization: 'token ' + SERVICE_TOKEN } : {}
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', { headers })
if (res.ok) {
const data = await res.json()
return data.message || 'authenticated'
}
} catch {}
return 'authenticated'
}
export async function logout () {
window.location.href = 'https://auth.targo.ca/if/flow/default-invalidation-flow/'
}

View File

@ -0,0 +1,211 @@
/**
* api/campaigns.js Client for Hub /campaigns endpoints.
*
* Mirrors services/targo-hub/lib/campaigns.js. All gift-campaign requests
* go through the Hub which handles ERPNext auth + Mailjet send + SSE
* progress broadcast.
*
* Functions:
* parseCsvs({ map_csv, giftbit_csv, multi }) preview matched send list
* createCampaign({ name, params, recipients }) save + return id
* listCampaigns() summaries
* getCampaign(id) full detail
* updateCampaign(id, patch) edit recipients/params
* sendCampaign(id) fire background worker
* campaignSseUrl(id) SSE URL for live updates
*/
import { HUB_URL } from 'src/config/hub'
async function hubFetch (path, { method = 'GET', body } = {}) {
const opts = { method, headers: { 'Content-Type': 'application/json' } }
if (body) opts.body = JSON.stringify(body)
const res = await fetch(`${HUB_URL}${path}`, opts)
const text = await res.text()
let data
try { data = text ? JSON.parse(text) : {} }
catch { throw new Error(`Invalid JSON from ${path}: ${text.slice(0, 200)}`) }
if (!res.ok) {
const msg = data.error || `HTTP ${res.status}`
const err = new Error(msg)
err.status = res.status
throw err
}
return data
}
export function parseCsvs ({ map_csv, giftbit_csv, multi = 'first' }) {
return hubFetch('/campaigns/parse', {
method: 'POST',
body: { map_csv, giftbit_csv, multi },
})
}
export function createCampaign ({ name, params, recipients }) {
return hubFetch('/campaigns', {
method: 'POST',
body: { name, params, recipients },
})
}
export function listCampaigns () {
return hubFetch('/campaigns').then(r => r.campaigns || [])
}
export function getCampaign (id) {
return hubFetch(`/campaigns/${encodeURIComponent(id)}`)
}
export function updateCampaign (id, patch) {
return hubFetch(`/campaigns/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: patch,
})
}
export function sendCampaign (id) {
return hubFetch(`/campaigns/${encodeURIComponent(id)}/send`, {
method: 'POST',
})
}
// Permanent deletion — removes the JSON on the hub. Used for clearing
// test campaigns from the list. Giftbit shortlinks are unaffected.
export function deleteCampaign (id) {
return hubFetch(`/campaigns/${encodeURIComponent(id)}`, {
method: 'DELETE',
})
}
// Inventory of every wrapper token across all campaigns, with status
// (active / expired / revoked / redeemed / pending). Used by the
// gifts inventory page to surface reassignable Giftbit shortlinks.
export function listGifts () {
return hubFetch('/campaigns/gifts').then(r => r.gifts || [])
}
// Kill switch — manually expire a single recipient's wrapper token so
// the underlying Giftbit URL becomes reassignable before the natural
// expiry date.
export function revokeGift (campaignId, rowIndex) {
return hubFetch(
`/campaigns/${encodeURIComponent(campaignId)}/recipients/${rowIndex}/revoke`,
{ method: 'POST' },
)
}
// Re-attempt a single failed recipient — resets status pending and
// fires the worker. Used for one-off failures the auto-retry didn't
// recover (rare transient Mailjet socket closes, etc.).
export function retryRecipient (campaignId, rowIndex) {
return hubFetch(
`/campaigns/${encodeURIComponent(campaignId)}/recipients/${rowIndex}/retry`,
{ method: 'POST' },
)
}
// Create a brand-new draft campaign targeted at the parent's non-clicked
// recipients. Uses gift-email-reminder-* templates + an urgency subject.
// The new campaign is returned but NOT auto-sent — the operator reviews
// and clicks Lancer l'envoi when ready.
export function createReminderCampaign (parentCampaignId) {
return hubFetch(
`/campaigns/${encodeURIComponent(parentCampaignId)}/reminder`,
{ method: 'POST' },
)
}
// Build the URL the browser hits to download the per-recipient CSV report
// (giftbit shortlink ↔ email ↔ name ↔ status). The hub serves it with the
// proper Content-Disposition so an <a download> click triggers a save.
export function campaignReportCsvUrl (id) {
return `${HUB_URL}/campaigns/${encodeURIComponent(id)}/report.csv`
}
// ── Image assets (self-hosted on the hub, for GrapesJS asset manager) ───────
export function listAssets () {
return hubFetch('/campaigns/assets').then(r => r.assets || [])
}
// Upload a File / Blob from the browser via base64-encoded JSON. Bypasses
// multipart parsing on the hub side (zero new deps) at the cost of ~33%
// payload overhead. Acceptable for the ≤5 MB images we permit.
export async function uploadAsset (file) {
const dataUrl = await new Promise((resolve, reject) => {
const r = new FileReader()
r.onload = () => resolve(r.result)
r.onerror = () => reject(new Error('FileReader failed'))
r.readAsDataURL(file)
})
return hubFetch('/campaigns/assets/upload', {
method: 'POST',
body: { name: file.name, data: dataUrl },
})
}
export function deleteAsset (filename) {
return hubFetch(`/campaigns/assets/${encodeURIComponent(filename)}`, {
method: 'DELETE',
})
}
// ── Template editing (used by the GrapesJS editor page) ─────────────────────
export function listTemplates () {
return hubFetch('/campaigns/templates').then(r => r.templates || [])
}
export function getTemplate (name) {
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`)
}
// saveTemplate(name, content, opts) — content is HTML by default.
// Optional opts.design = Unlayer design JSON (persisted alongside HTML so the
// editor can re-load the visual state on next open).
// Legacy opts.format = 'mjml' still supported for older callers (sends mjml).
export function saveTemplate (name, content, { format = 'html', design = null } = {}) {
const body = format === 'mjml' ? { mjml: content } : { html: content }
if (design) body.design = design
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`, {
method: 'PUT',
body,
})
}
export function previewTemplate (name, { html, vars } = {}) {
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/preview`, {
method: 'POST',
body: { html, vars },
})
}
// Translate the source template to a target language via Gemini.
// targetName must match the source's prefix (e.g. gift-email-fr → gift-email-en).
// override=true required if the target already exists.
export function translateTemplate (srcName, targetName, { override = false } = {}) {
return hubFetch(
`/campaigns/templates/${encodeURIComponent(srcName)}/translate-to/${encodeURIComponent(targetName)}`,
{ method: 'POST', body: { override } },
)
}
// Send ONE rendered email to a specific address for visual QA.
// Pass { to, vars, from?, subject? } — defaults filled in server-side.
export function testSendTemplate (name, { to, vars, from, subject } = {}) {
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/test-send`, {
method: 'POST',
body: { to, vars, from, subject },
})
}
/**
* Returns the URL for the SSE channel of one campaign. The Hub broadcasts on
* topic `campaign:<id>` so we subscribe to that single topic. Use with:
* const es = new EventSource(campaignSseUrl(id))
* es.addEventListener('recipient-update', ev => { ... })
* es.addEventListener('campaign-done', ev => { ... })
*/
export function campaignSseUrl (id) {
return `${HUB_URL}/sse?topics=campaign:${encodeURIComponent(id)}`
}

View File

@ -0,0 +1,171 @@
// ── ERPNext Dispatch resource calls ─────────────────────────────────────────
// All ERPNext fetch() calls live here.
// Swap BASE_URL in config/erpnext.js to change the target server.
// ─────────────────────────────────────────────────────────────────────────────
import { BASE_URL } from 'src/config/erpnext'
import { authFetch } from './auth'
async function apiGet (path) {
const res = await authFetch(BASE_URL + path)
const data = await res.json()
if (data.exc) {
console.error('[apiGet] ERPNext error:', path.slice(0, 120), data.exc.slice ? data.exc.slice(0, 200) : data.exc)
throw new Error(data.exc)
}
return data
}
async function apiPut (doctype, name, body) {
const res = await authFetch(
`${BASE_URL}/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
)
const text = await res.text()
if (!res.ok) console.error(`[API] PUT ${doctype}/${name} failed:`, res.status, text)
let data
try { data = JSON.parse(text) } catch { throw new Error(`PUT ${doctype}/${name}: invalid JSON — ${text.slice(0, 200)}`) }
if (data.exc) throw new Error(data.exc)
return data
}
// Single-call fetch with all fields + child tables
async function listDocs (doctype, fields = '["*"]', filters = null, limit = 200) {
let url = `/api/resource/${encodeURIComponent(doctype)}?fields=${fields}&limit_page_length=${limit}`
if (filters) url += '&filters=' + encodeURIComponent(JSON.stringify(filters))
const data = await apiGet(url)
return data.data || []
}
// Fetch single doc (needed for child tables not returned by list)
async function fetchDoc (doctype, name) {
return (await apiGet(`/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`)).data
}
export async function fetchTechnicians () {
// Fast: single list call (no child tables = no tags)
const techs = await listDocs('Dispatch Technician', '["*"]', null, 100)
return techs // tags loaded lazily via loadTechTags()
}
// Background: fetch individual docs to get child tables (tags)
export async function loadTechTags (techNames) {
const results = await Promise.all(techNames.map(n => fetchDoc('Dispatch Technician', n).catch(() => null)))
return results.filter(Boolean)
}
// Fast: single list call, no child tables (assistants/tags come empty)
export async function fetchJobsFast (filters = null) {
return listDocs('Dispatch Job', '["*"]', filters, 500)
}
// Full: adds child tables (assistants, tags) — use only for jobs that need them
export async function fetchJobFull (name) {
return fetchDoc('Dispatch Job', name)
}
// Legacy: fetches all with child tables (slow — avoid)
export async function fetchJobs (filters = null) {
return fetchJobsFast(filters)
}
export async function updateJob (name, payload) {
return apiPut('Dispatch Job', name, payload)
}
export async function createJob (payload) {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Job`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data.data
}
export async function publishJobs (jobNames) {
const results = await Promise.allSettled(
jobNames.map(name => apiPut('Dispatch Job', name, { published: 1 }))
)
const failed = results.filter(r => r.status === 'rejected')
if (failed.length) console.warn(`[publishJobs] ${failed.length}/${jobNames.length} failed`)
return { published: jobNames.length - failed.length, failed: failed.length }
}
export async function updateTech (name, payload) {
return apiPut('Dispatch Technician', name, payload)
}
export async function createTech (payload) {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Technician`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) },
)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data.data
}
export async function deleteTech (name) {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Technician/${encodeURIComponent(name)}`,
{ method: 'DELETE' },
)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
const msg = data._server_messages ? JSON.parse(JSON.parse(data._server_messages)[0]).message : data.exception || 'Delete failed'
throw new Error(msg)
}
}
export async function fetchTags () {
const data = await apiGet('/api/resource/Dispatch%20Tag?fields=["name","label","color","category"]&limit=200')
return data.data || []
}
export async function createTag (label, category = 'Custom', color = '#6b7280') {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Tag`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label, category, color }),
},
)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data.data
}
export async function updateTag (name, payload) {
return apiPut('Dispatch Tag', name, payload)
}
export async function renameTag (oldName, newName) {
const res = await authFetch(
`${BASE_URL}/api/method/frappe.client.rename_doc`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doctype: 'Dispatch Tag', old_name: oldName, new_name: newName }),
},
)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data.message // returns new name
}
export async function deleteTag (name) {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Tag/${encodeURIComponent(name)}`,
{ method: 'DELETE' },
)
if (!res.ok) throw new Error('Delete tag failed')
}

View File

@ -0,0 +1,28 @@
/**
* Email Queue admin API calls targo-hub /email-queue/* (proxies ERPNext).
* Lets ops see + delete outbound emails sitting in ERPNext's queue.
*/
import { HUB_URL as HUB } from 'src/config/hub'
export async function listEmailQueue (status = '') {
const qs = status ? `?status=${encodeURIComponent(status)}` : ''
const r = await fetch(HUB + '/email-queue' + qs)
if (!r.ok) throw new Error('Email queue API: ' + r.status)
return r.json()
}
export async function deleteEmailQueueItem (name) {
const r = await fetch(HUB + '/email-queue/' + encodeURIComponent(name), { method: 'DELETE' })
if (!r.ok) throw new Error('Suppression échouée: ' + r.status)
return r.json()
}
export async function purgeEmailQueue (status = 'Not Sent') {
const r = await fetch(HUB + '/email-queue/purge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
})
if (!r.ok) throw new Error('Purge échouée: ' + r.status)
return r.json()
}

97
apps/ops/src/api/erp.js Normal file
View File

@ -0,0 +1,97 @@
// Unified ERPNext API helper for all modules
import { BASE_URL } from 'src/config/erpnext'
import { authFetch } from './auth'
// List documents with filters, fields, pagination
export async function listDocs (doctype, { filters = {}, or_filters, fields = ['name'], limit = 20, offset = 0, orderBy = 'creation desc' } = {}) {
const params = new URLSearchParams({
fields: JSON.stringify(fields),
filters: JSON.stringify(filters),
limit_page_length: limit,
limit_start: offset,
order_by: orderBy,
})
if (or_filters) params.set('or_filters', JSON.stringify(or_filters))
const res = await authFetch(BASE_URL + '/api/resource/' + encodeURIComponent(doctype) + '?' + params)
if (!res.ok) throw new Error('API error: ' + res.status)
const data = await res.json()
return data.data || []
}
// Get single document
export async function getDoc (doctype, name) {
const res = await authFetch(BASE_URL + '/api/resource/' + encodeURIComponent(doctype) + '/' + encodeURIComponent(name))
if (!res.ok) throw new Error('Not found: ' + name)
const data = await res.json()
return data.data
}
// Search (for autocomplete / search bars)
export async function searchDocs (doctype, text, { filters = {}, fields = ['name'], limit = 20 } = {}) {
const params = new URLSearchParams({
doctype,
txt: text,
filters: JSON.stringify(filters),
limit_page_length: limit,
})
const res = await authFetch(BASE_URL + '/api/method/frappe.client.get_list?' + params)
if (!res.ok) return []
const data = await res.json()
return data.message || []
}
// Create a new document
export async function createDoc (doctype, data) {
const res = await authFetch(BASE_URL + '/api/resource/' + encodeURIComponent(doctype), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
const json = await res.json()
if (!res.ok) {
const msg = json?.exception?.split('\n')[0] || json?.message || ('Create failed: ' + res.status)
throw new Error(msg)
}
return json.data
}
// Update a document (partial update)
export async function updateDoc (doctype, name, data) {
const url = BASE_URL + '/api/resource/' + encodeURIComponent(doctype) + '/' + encodeURIComponent(name)
const res = await authFetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Update failed: ' + res.status)
const json = await res.json()
return json.data
}
// Delete a document
export async function deleteDoc (doctype, name) {
const url = BASE_URL + '/api/resource/' + encodeURIComponent(doctype) + '/' + encodeURIComponent(name)
const res = await authFetch(url, { method: 'DELETE' })
if (!res.ok) {
let msg = 'Delete failed: ' + res.status
try {
const json = await res.json()
msg = json?.exception?.split('\n')[0] || json?._server_messages || json?.message || msg
} catch (_) {}
throw new Error(msg)
}
return true
}
// Count documents
export async function countDocs (doctype, filters = {}, or_filters) {
const params = new URLSearchParams({
doctype,
filters: JSON.stringify(filters),
})
if (or_filters) params.set('or_filters', JSON.stringify(or_filters))
const res = await authFetch(BASE_URL + '/api/method/frappe.client.get_count?' + params)
if (!res.ok) return 0
const data = await res.json()
return data.message || 0
}

View File

@ -0,0 +1,104 @@
/**
* api/flow-templates.js Client for the Hub /flow/templates CRUD endpoints.
*
* Mirrors services/targo-hub/lib/flow-templates.js. All requests go through
* the Hub (which handles ERPNext auth server-side).
*
* Functions:
* listFlowTemplates({ category?, applies_to?, trigger_event?, is_active?, q? })
* getFlowTemplate(name)
* createFlowTemplate(body)
* updateFlowTemplate(name, patch)
* deleteFlowTemplate(name)
* duplicateFlowTemplate(name, newName?)
*
* All functions return the parsed JSON body or throw on network / 4xx / 5xx.
*/
import { HUB_URL } from 'src/config/hub'
/** Fetch helper with error normalization. */
async function hubFetch (path, { method = 'GET', body } = {}) {
const opts = { method, headers: { 'Content-Type': 'application/json' } }
if (body) opts.body = JSON.stringify(body)
const res = await fetch(`${HUB_URL}${path}`, opts)
const text = await res.text()
let data
try { data = text ? JSON.parse(text) : {} }
catch { throw new Error(`Invalid JSON from ${path}: ${text.slice(0, 200)}`) }
if (!res.ok) {
const msg = data.error || `HTTP ${res.status}`
const err = new Error(msg)
err.status = res.status
err.detail = data.detail
throw err
}
return data
}
/**
* List flow templates with optional filters.
* @param {Object} filters { category, applies_to, trigger_event, is_active, q, limit }
* @returns {Promise<Array>} list of templates (without flow_definition body)
*/
export async function listFlowTemplates (filters = {}) {
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(filters)) {
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v))
}
const path = `/flow/templates${qs.toString() ? '?' + qs.toString() : ''}`
const data = await hubFetch(path)
return data.templates || []
}
/**
* Fetch a single template with its parsed flow_definition.
* @param {string} name FT-00001 etc.
*/
export async function getFlowTemplate (name) {
const data = await hubFetch(`/flow/templates/${encodeURIComponent(name)}`)
return data.template
}
/**
* Create a new (user) template. is_system is forced to 0 by the API.
* @param {Object} body { template_name, category, applies_to, flow_definition, ... }
*/
export async function createFlowTemplate (body) {
const data = await hubFetch('/flow/templates', { method: 'POST', body })
return data.template
}
/**
* Patch an existing template. Version is auto-bumped by the API.
* @param {string} name
* @param {Object} patch subset of fields
*/
export async function updateFlowTemplate (name, patch) {
const data = await hubFetch(`/flow/templates/${encodeURIComponent(name)}`, {
method: 'PUT', body: patch,
})
return data.template
}
/**
* Delete a template. Blocked server-side if is_system=1.
* @param {string} name
*/
export async function deleteFlowTemplate (name) {
await hubFetch(`/flow/templates/${encodeURIComponent(name)}`, { method: 'DELETE' })
}
/**
* Duplicate a template (e.g. to customize a system template).
* Creates an inactive copy (is_active=0) for user to review before enabling.
* @param {string} name
* @param {string} [newName] optional override (defaults to "<name> (copie)")
*/
export async function duplicateFlowTemplate (name, newName) {
const body = newName ? { template_name: newName } : {}
const data = await hubFetch(`/flow/templates/${encodeURIComponent(name)}/duplicate`, {
method: 'POST', body,
})
return data.template
}

104
apps/ops/src/api/ocr.js Normal file
View File

@ -0,0 +1,104 @@
/**
* OCR / Vision client all calls go through targo-hub, which runs Gemini
* 2.5 Flash. We deliberately do NOT call Ollama from the ops SPA because the
* ops/ERPNext VM has no GPU; invoice OCR used to hit a local Ollama vision
* model (llama3.2-vision), but that's now centralized in the hub so every
* app (ops, field-as-ops `/j`, future client portal) gets the same model,
* same prompt, same normalization.
*
* Endpoints used:
* POST {HUB_URL}/vision/barcodes { barcodes: string[] }
* POST {HUB_URL}/vision/equipment { brand, model, serial_number, mac_address, gpon_sn, hw_version, equipment_type, barcodes }
* POST {HUB_URL}/vision/invoice { vendor, vendor_address, invoice_number, date, due_date, subtotal, tax_gst, tax_qst, total, currency, items[], notes }
*
* All three are public (no Authentik header) the hub rate-limits and logs.
*/
import { HUB_URL } from 'src/config/hub'
const VISION_BARCODES = `${HUB_URL}/vision/barcodes`
const VISION_EQUIPMENT = `${HUB_URL}/vision/equipment`
const VISION_INVOICE = `${HUB_URL}/vision/invoice`
/** Strip any `data:image/...;base64,` prefix — hub accepts either form but
* we normalize here so error messages + logs stay consistent. */
function stripDataUri (base64Image) {
return String(base64Image || '').replace(/^data:image\/[^;]+;base64,/, '')
}
/**
* Send a photo to Gemini (via hub) for bill/invoice OCR.
* @param {string} base64Image base64 or data URI
* @returns {Promise<object>} Parsed invoice data (schema in targo-hub/lib/vision.js)
* @throws {Error} on network/API failure caller decides whether to retry
*/
export async function ocrBill (base64Image) {
const res = await fetch(VISION_INVOICE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: stripDataUri(base64Image) }),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error('Invoice OCR failed: ' + (text || res.status))
}
return res.json()
}
/**
* Send a photo to Gemini (via hub) for generic barcode / serial extraction.
* @param {string} base64Image base64 or data URI
* @returns {Promise<{ barcodes: string[] }>}
* @throws {Error} on network/API failure `useScanner` uses this signature
* to decide whether to queue the photo for retry (see isRetryable()).
*/
export async function scanBarcodes (base64Image) {
const res = await fetch(VISION_BARCODES, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: base64Image }),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error('Vision scan failed: ' + (text || res.status))
}
const data = await res.json()
return { barcodes: data.barcodes || [] }
}
/**
* Structured equipment label scan richer schema than scanBarcodes for
* ONT/ONU/router labels. Unique to ops (was not in the old field client).
* @param {string} base64Image base64 or data URI
* @returns {Promise<object>} See EQUIP_SCHEMA in targo-hub/lib/vision.js
*/
export async function scanEquipmentLabel (base64Image) {
const res = await fetch(VISION_EQUIPMENT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: base64Image }),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error('Equipment scan failed: ' + (text || res.status))
}
return res.json()
}
/**
* Vision service health probe.
*
* Historically this pinged `/ollama/api/tags` to confirm the local vision
* model was warm. Now that everything is on Gemini via the hub, we just
* check the hub is reachable the hub itself validates AI_API_KEY on
* startup, so if it's up, Gemini works.
*/
export async function checkOllamaStatus () {
try {
const res = await fetch(`${HUB_URL}/health`, { method: 'GET' })
if (!res.ok) return { online: false, error: 'HTTP ' + res.status }
return { online: true, models: ['gemini-2.5-flash'], hasVision: true }
} catch (e) {
return { online: false, error: e.message }
}
}

View File

@ -0,0 +1,74 @@
// ── Job Offer API — Uber-style offer/accept for dispatch jobs ────────────────
// Offers are stored as "Dispatch Offer" docs in ERPNext.
// Flow: dispatcher/customer creates offer → matching techs notified → tech accepts → job assigned
// ─────────────────────────────────────────────────────────────────────────────
import { BASE_URL } from 'src/config/erpnext'
import { authFetch } from './auth'
async function apiGet (path) {
const res = await authFetch(BASE_URL + path)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data
}
async function apiPut (doctype, name, body) {
const res = await authFetch(
`${BASE_URL}/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`,
{ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) },
)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data
}
// ── CRUD ─────────────────────────────────────────────────────────────────────
export async function fetchOffers (filters = null) {
let url = '/api/resource/Dispatch%20Offer?fields=["*"]&limit_page_length=200&order_by=creation+desc'
if (filters) url += '&filters=' + encodeURIComponent(JSON.stringify(filters))
const data = await apiGet(url)
return data.data || []
}
export async function fetchActiveOffers () {
return fetchOffers([['status', 'in', ['open', 'pending']]])
}
export async function createOffer (payload) {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Offer`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) },
)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data.data
}
export async function updateOffer (name, payload) {
return apiPut('Dispatch Offer', name, payload)
}
// ── Offer actions ────────────────────────────────────────────────────────────
export async function acceptOffer (offerName, techId) {
return updateOffer(offerName, { status: 'accepted', accepted_by: techId, accepted_at: new Date().toISOString() })
}
export async function declineOffer (offerName, techId, reason = '') {
// Record decline — we don't close the offer, just track who declined
return updateOffer(offerName, {
declined_techs: JSON.stringify([
...JSON.parse((await apiGet(`/api/resource/Dispatch%20Offer/${offerName}`)).data?.declined_techs || '[]'),
{ techId, reason, at: new Date().toISOString() },
]),
})
}
export async function cancelOffer (offerName) {
return updateOffer(offerName, { status: 'cancelled' })
}
export async function expireOffer (offerName) {
return updateOffer(offerName, { status: 'expired' })
}

View File

@ -0,0 +1,46 @@
// ── Dispatch Preset CRUD — shared resource group presets ─────────────────────
import { BASE_URL } from 'src/config/erpnext'
import { authFetch } from './auth'
async function apiGet (path) {
const res = await authFetch(BASE_URL + path)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data
}
export async function fetchPresets () {
const data = await apiGet('/api/resource/Dispatch%20Preset?fields=["*"]&limit_page_length=100&order_by=creation+asc')
return data.data || []
}
export async function createPreset (payload) {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Preset`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) },
)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data.data
}
export async function updatePreset (name, payload) {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Preset/${encodeURIComponent(name)}`,
{ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) },
)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data.data
}
export async function deletePreset (name) {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Preset/${encodeURIComponent(name)}`,
{ method: 'DELETE' },
)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.exception || 'Delete failed')
}
}

Some files were not shown because too many files have changed in this diff Show More