Commit Graph

36 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
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
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