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.
- 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.
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>
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>
- 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>
- 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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
- 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>
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>
- 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>
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>
- 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>
- 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>
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>
- 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>
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>
- 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>
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>