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>
20 KiB
Tech Mobile — Field Technician App
One feature, three delivery surfaces. The tech-facing UI (today's jobs, scan-to-identify, equipment install/remove, diagnostic probes) has been rewritten twice and the codebase currently carries all three copies. Know which one a bug is actually in before you start editing.
Last refreshed: 2026-04-22
1. The three surfaces (read this first)
| Surface | URL in the SMS today | Hosted at | Status |
|---|---|---|---|
| Legacy SSR | https://msg.gigafibre.ca/t/{jwt} |
services/targo-hub/lib/tech-mobile.js — single-file server-rendered HTML+inline JS |
Live. This is what techs actually open today. |
| Transitional PWA | — (self-navigated) | apps/field/ — standalone Quasar PWA at https://erp.gigafibre.ca/field/ behind Authentik |
Live but retiring. Used by techs who bookmark it; no SMS points here. |
| Unified Ops tech module | — (target) | apps/ops/src/modules/tech/ mounted at https://erp.gigafibre.ca/ops/#/j/ |
Live, on deck. Same auth path as Ops; magic-link handler at /j/:token is wired but SMS generator still points at the SSR page. |
The migration plan is to flip FIELD_APP_URL in services/targo-hub/lib/config.js
from https://msg.gigafibre.ca to https://erp.gigafibre.ca/ops/#/j (or
update generateLink() to emit that URL directly), at which point the Vue
SPA becomes the primary surface and the SSR page becomes a fallback for
browsers too old to parse the SPA. Both the legacy SSR page and the Vue
pages talk to the same hub endpoints, so cutover is a one-line change plus a
regression pass.
┌────────────────────────────────────────┐
│ Dispatch Job created / reassigned │
│ (Ops staff in /ops/#/dispatch) │
└──────────────────┬─────────────────────┘
│ POST /magic-link/refresh
│ (nightly cron + on-demand)
▼
┌───────────────────────────────────┐
│ targo-hub / magic-link.js │
│ mint JWT (HS256, 72h) │
│ + Twilio SMS to tech.phone │
└──────────────────┬────────────────┘
│ link:
│ https://msg.gigafibre.ca/t/{jwt}
▼
┌──────────────────────────────────────────┐
│ TECH TAPS LINK ON PHONE │
└──────────────────┬───────────────────────┘
│ (today) │ (target)
▼ ▼
┌──────────────────────────┐ ┌─────────────────────────────┐
│ hub/lib/tech-mobile.js │ │ apps/ops/src/modules/tech/ │
│ server-renders HTML, │ │ Vue SPA; magic-link route │
│ inline JS, <15KB │ │ /j/:token persists JWT in │
│ Same /vision/* calls │ │ localStorage then redirects │
└──────────────────────────┘ └─────────────────────────────┘
│ │
└──────┬──────────────┘
│ all surfaces call:
▼
POST https://msg.gigafibre.ca/vision/barcodes
POST https://msg.gigafibre.ca/vision/equipment
POST /api/resource/Dispatch Job/{name}
POST /api/resource/Service Equipment
2. Auth: magic-link JWT (shared by all three surfaces)
Implemented in: services/targo-hub/lib/magic-link.js
Two token shapes:
// Job-scoped: gives access to a single Dispatch Job
{ sub: techId, job: jobId, iat, exp }
// Tech-scoped: gives access to every job assigned to that tech
{ sub: techId, scope: 'all', iat, exp }
TTL: 72 hours (hard-coded in generateLink() / generateTechLink()).
The customer portal uses 24h; techs get longer so a Monday SMS covers a
full ticket lookahead.
Signing: HS256 using JWT_SIGNING_KEY from the hub env. Same secret
signs the customer-portal tokens — rotating it logs everyone out, so it's
only rotated on incident.
SMS sender flow (magic-link.js → twilio.js):
POST /magic-link/refresh { tech_id }
↓ ERPNext: lookup Dispatch Technician → phone
↓ generate tech-level JWT (scope=all, 72h)
↓ Twilio: SMS "Voici votre nouveau lien pour accéder à vos tâches: <url>"
↓ log to targo-hub stdout (tech_id + phone number, no token)
200 { ok: true, sent_to: "+15145551234" }
Token verification:
GET /magic-link/verify?token=<jwt>
200 { ok, tech_id, job_id?, scope, exp }
401 { error: 'invalid_or_expired',
message: 'Ce lien a expiré. Un nouveau lien vous sera envoyé par SMS.' }
On the Vue SPA side (apps/ops/src/modules/tech/pages/TechTasksPage.vue,
route /j/:token): the component reads route.params.token, calls
/magic-link/verify, stores {tech_id, exp} in localStorage, then
router.replace({ name: 'tech-tasks' }) so the URL no longer contains the
raw JWT. Any subsequent 401 re-triggers the expired flow.
3. Scanner — native camera + Gemini (no JS library)
This is the whole point of the feature. No
html5-qrcode, nohtml5core, noZXing, noBarcodeDetectorpolyfill. Just the browser's file picker in "camera" mode and a POST to the hub.
Implementation: apps/ops/src/composables/useScanner.js (Vue SPA) and
apps/field/src/composables/useScanner.js (transitional PWA). The two
files are byte-identical minus imports — kept in sync by the revert
bookmark at commit e50ea88. The SSR page
(services/targo-hub/lib/tech-mobile.js) inlines an equivalent ~40 lines.
3a. The markup
<input
ref="cameraInput"
type="file"
accept="image/*"
capture="environment"
class="hidden"
@change="onPhoto" />
capture="environment" tells iOS Safari / Android Chrome to open the rear
camera directly with autofocus engaged. We do not stream getUserMedia
ourselves — a JS camera has worse focus on close-up serial labels than the
OS camera does, and burns battery. The user frames one photo, taps the
shutter, the <input>'s change event fires with a File object, and we
hand it off.
3b. The pipeline
File (1–4 MB JPEG)
↓ createImageBitmap → 400px thumbnail (for instant UI preview)
↓ createImageBitmap → 1600px long-edge JPEG @ quality 0.92
↓ base64 encode (~500 KB over the wire)
↓ POST https://msg.gigafibre.ca/vision/barcodes
↓ body: { image: "data:image/jpeg;base64,…" }
↓ 200 { barcodes: ["1608K44D9E79FAFF5", "0418D6A1B2C3", "TPLG-A1…"] }
↓ onNewCode(code) callback fires per string, up to MAX_BARCODES = 5
Timeout: SCAN_TIMEOUT_MS = 8000. Beyond that we give up and either
(a) offer a retry, or (b) enqueue the image to the offline queue if
offline.
Why 3 codes max: the hub's responseSchema caps barcodes at 3 items
(see services/targo-hub/lib/vision.js). A single label often has 3–5
barcodes (EAN, S/N, MAC) stacked — we take the first 3 Gemini ranks as
most-confident and let the user tap the one that matches. The SPA keeps a
rolling window of 5 so a tech can re-scan without losing the prior match.
3c. Equipment-label mode (structured, not just strings)
When the user opens the scanner from the "Scanner un code-barres" option
inside the equipment bottom-sheet (not the top-level /j/scan page), we
hit /vision/equipment instead. That endpoint returns structured fields
ready to pre-fill the "Create equipment" dialog:
{
"equipment": [
{
"serial": "1608K44D9E79FAFF5",
"mac": "04:18:D6:A1:B2:C3",
"brand": "Nokia",
"model": "G-140W-C",
"type": "ONT"
}
]
}
maxItems: 5. Same 1600px JPEG. Same 8s timeout. Same offline queue.
3d. Offline queue
Implemented in: apps/ops/src/stores/offline.js (Pinia) via
idb-keyval at key tech-vision-queue.
// When fetch() rejects or navigator.onLine === false:
enqueueVisionScan({ id, endpoint, imageDataUri, ts, consumer })
→ persist to IndexedDB
// On navigator.online event:
watch(isOnline, async now => {
if (now) flushQueue() // replays each scan, fires original consumer
})
The consumer identifier ('tech-scan' | 'tech-equip-scan' | …) lets the
UI route the late result to the right dialog once the user is back online.
Scans older than 7 days are dropped on boot.
4. The Vue SPA surface (apps/ops/src/modules/tech/)
Routes, defined in apps/ops/src/router/index.js:
path: '/j',
component: TechLayout,
children: [
{ path: '', name: 'tech-tasks', component: TechTasksPage },
{ path: 'job/:name', name: 'tech-job', component: TechJobDetailPage, props: true },
{ path: 'scan', name: 'tech-scan', component: TechScanPage },
{ path: 'device/:serial', name: 'tech-device', component: TechDevicePage, props: true },
{ path: 'diagnostic', name: 'tech-diag', component: TechDiagnosticPage },
{ path: 'more', name: 'tech-more', component: TechMorePage },
{ path: ':token', name: 'tech-magic', component: TechTasksPage, props: true }, // must be LAST
],
The magic-link route is deliberately the last child — Vue Router's pattern
matcher would otherwise swallow /j/scan as token=scan. If you add a
new static child, put it above the :token entry.
4a. TechTasksPage.vue — today's schedule
Fetches Dispatch Job rows filtered by assigned_tech == sub and
scheduled_date == today (UTC-Toronto). Groups them by status
("À venir", "En cours", "Terminée"). Each row navigates to
/j/job/:name.
4b. TechJobDetailPage.vue — the main tech surface
458 lines. This is where the ported equipment UX lives — see §5.
Three cards:
| Card | Fields | Writes? |
|---|---|---|
| Info | Type (Installation / Dépannage / …), Priority, Duration, Status chip, Description textarea | Yes — saveField() debounced 500ms PUTs to Dispatch Job |
| Location | Address (click → Google Maps GPS), contact name, contact phone (expandable, lazy-loads Service Location) |
No — read-only |
| Equipment | List of Service Equipment linked to the job; "Ajouter un équipement" bottom-sheet |
Yes — see §5 |
Bottom-of-page action buttons:
- Status
Scheduled→ "Démarrer" → PUT statusIn Progress - Status
In Progress→ "Terminer" → PUT statusCompleted - Status
Completed→ no button (dispatch has to reopen)
Both status values have legacy aliases (assigned, in_progress) because
the SSR page uses ERPNext's Title Case and older Python scripts emit
lowercase. The computed statusLabel handles both.
4c. TechScanPage.vue — standalone scanner
The top-level scanner route. Accepts any barcode, looks up by serial in
ERPNext, routes to /j/device/:serial on a hit or offers "Create
equipment" on a miss. Useful for techs auditing a van inventory or
confirming a serial before scheduled install time.
4d. TechDevicePage.vue — per-device detail
GET Service Equipment/:serial → shows brand, model, MAC, install date,
linked customer, linked address, recent diagnostic runs (via
useSpeedTest composable). "Retirer" button unlinks from current job
(PUT linked_job: null) but preserves the doc for audit.
4e. TechDiagnosticPage.vue — speed test + GenieACS probe
Runs an in-browser speed test via useSpeedTest and, if the device is
ACS-managed, fires a GetParameterValues against GenieACS through
targo-hub /acs/probe. Results render as a one-page PDF the tech can
SMS to the customer.
4f. TechMorePage.vue — settings, logout, version
Shows tech name, token expiry countdown, link to "Request new magic
link" (POSTs /magic-link/refresh), and a hard logout that wipes
localStorage.
5. Equipment management (the UX that was re-ported 2026-04-22)
This section drove today's port from apps/field/src/pages/JobDetailPage.vue
→ apps/ops/src/modules/tech/pages/TechJobDetailPage.vue. The Ops SPA
stub had dropped it. It's back.
5a. The bottom-sheet trigger
<q-btn flat dense label="Ajouter" icon="add" @click="addEquipmentMenu = true" />
<q-dialog v-model="addEquipmentMenu" position="bottom">
<q-list>
<q-item clickable v-close-popup @click="goToScanner">
<q-item-section avatar><q-icon name="qr_code_scanner" /></q-item-section>
<q-item-section>Scanner un code-barres / QR</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="searchEquipDialog = true">
<q-item-section avatar><q-icon name="search" /></q-item-section>
<q-item-section>Rechercher un équipement existant</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="createEquipDialog = true">
<q-item-section avatar><q-icon name="add_circle" /></q-item-section>
<q-item-section>Créer un nouvel équipement</q-item-section>
</q-item>
</q-list>
</q-dialog>
5b. Scan → link
goToScanner() router.push({ name: 'tech-scan' }), passing the current
jobName in query string. TechScanPage on a barcode hit calls
linkEquipToJob(equipment) which PUTs linked_job: jobName on the
Service Equipment row.
5c. Search → link
Debounced 400ms on eqSearchText. First query is by serial_number;
if that returns empty and the string looks like a MAC
(hex+separators), falls through to mac_address. Results render as
selectable rows; tap → linkEquipToJob().
5d. Create → link (pre-filled from scan or blank)
const newEquip = ref({
serial_number: '', // autofocus
equipment_type: 'ONT', // select
brand: '', // Marque
model: '', // Modèle
mac_address: '', // MAC optionnel
})
const eqTypes = ['ONT', 'Modem', 'Routeur', 'Décodeur TV',
'VoIP', 'Amplificateur', 'Splitter', 'Câble', 'Autre']
createAndLinkEquip() POSTs a Service Equipment doc with these fields
plus status: 'In Service', linked_job: jobName,
service_location: job.service_location. The doc name is auto-generated
(ERPNext auto-series EQ-.####).
5e. "Retirer" (unlink from job, preserve doc)
Button on each equipment row. PUT linked_job: null + optional
status: 'Returned'. The doc itself is never deleted — audit trail
matters.
6. Hub API endpoints (all three surfaces call these)
Direct ERPNext calls via the tech's JWT (the Vue SPA wraps these in
src/api/erp.js):
GET /api/resource/Dispatch%20Job?filters=… → list today's jobs
GET /api/resource/Dispatch%20Job/:name → job detail
PUT /api/resource/Dispatch%20Job/:name → status, priority, etc.
GET /api/resource/Service%20Equipment?filters=… → list linked
GET /api/resource/Service%20Equipment/:serial → detail
POST /api/resource/Service%20Equipment → create
PUT /api/resource/Service%20Equipment/:serial → link/unlink
GET /api/resource/Service%20Location/:name → expand location
Hub-mediated calls (all https://msg.gigafibre.ca/…):
POST /vision/barcodes → Gemini vision, {image} → {barcodes: []}
POST /vision/equipment → Gemini vision, {image} → {equipment: []}
POST /magic-link/refresh → reissue + SMS → {sent_to: "+1…"}
GET /magic-link/verify?token → check expiry → {tech_id, exp}
POST /acs/probe → GenieACS GetParamValues → {params: {}}
The legacy SSR surface at /t/{token}/… replicates a subset of these
(/scan, /vision, /equip, /equip-remove, /status, /catalog,
/equip-list) but as token-scoped paths rather than REST + JWT header.
When the SSR page is retired those paths will 410 Gone.
7. File inventory
apps/ops/src/modules/tech/pages/
├── TechTasksPage.vue (today's jobs, grouped by status)
├── TechJobDetailPage.vue ← ported 2026-04-22 (equipment UX)
├── TechScanPage.vue (standalone scanner)
├── TechDevicePage.vue (per-device detail)
├── TechDiagnosticPage.vue (speed test + ACS probe)
└── TechMorePage.vue (settings, logout, token expiry)
apps/ops/src/layouts/
└── TechLayout.vue (bottom tab bar, mobile-optimized viewport)
apps/ops/src/composables/
└── useScanner.js (native <input> + Gemini, offline queue)
apps/ops/src/stores/
└── offline.js (IndexedDB queue, online-event flush)
apps/ops/src/api/
└── erp.js (getDoc, listDocs, createDoc, updateDoc wrappers)
apps/field/ ← transitional PWA, same layout, retiring
└── src/pages/JobDetailPage.vue (the 526-line reference for the port)
services/targo-hub/lib/
├── tech-mobile.js ← legacy SSR surface at /t/{token}
├── magic-link.js ← JWT mint/verify/refresh, shared w/ portal
└── vision.js ← /vision/barcodes + /vision/equipment
8. Open questions / known gaps
- Cutover: flip
FIELD_APP_URL(orgenerateLink()) to point athttps://erp.gigafibre.ca/ops/#/jand retire the SSR page. Needs a regression sweep of the equipment bottom-sheet on iOS Safari 16 and Android Chrome 120. - Offline create-equipment:
createDocis not queued yet. If a tech creates a device while offline the operation silentlycatch()es. Needs a write queue analogous toenqueueVisionScan. - Dual-language strings: UI is French-only. Roadmap item to
extract to
src/i18n/once Ops desktop i18n lands. - Equipment type normalization:
eqTypesarray is hard-coded. ERPNext has an "Equipment Type" doctype — wire the dropdown to it so new types don't require a frontend deploy. - GenieACS auto-probe: currently the tech has to navigate to
/j/diagnosticmanually. Could fire a background probe the moment a device is scanned.
9. Related docs
- features/vision-ocr.md — the Gemini pipeline backing the scanner
- features/dispatch.md — where
Dispatch Jobrows are created + assigned - features/customer-portal.md — same magic-link pattern, 24h TTL, different token schema
- features/cpe-management.md — GenieACS probe called by
TechDiagnosticPage - architecture/overview.md — Traefik routes, Authentik wrapping, hub placement
- architecture/module-interactions.md — cross-module read/write matrix
Back to docs/features/README.md · docs/README.md