# 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. ```text ┌────────────────────────────────────────┐ │ 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: ```js // 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`):** ```text 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: " ↓ log to targo-hub stdout (tech_id + phone number, no token) 200 { ok: true, sent_to: "+15145551234" } ``` **Token verification:** ```text GET /magic-link/verify?token= 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`, no > `html5core`, no `ZXing`, no `BarcodeDetector` polyfill. 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 ```html ``` `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 ``'s `change` event fires with a `File` object, and we hand it off. ### 3b. The pipeline ```text 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: ```json { "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`. ```js // 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`: ```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 status `In Progress` - Status `In Progress` → "Terminer" → PUT status `Completed` - 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 ```vue Scanner un code-barres / QR Rechercher un équipement existant Créer un nouvel équipement ``` ### 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) ```js 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 ```text 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 + 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` (or `generateLink()`) to point at `https://erp.gigafibre.ca/ops/#/j` and retire the SSR page. Needs a regression sweep of the equipment bottom-sheet on iOS Safari 16 and Android Chrome 120. - [ ] **Offline create-equipment**: `createDoc` is not queued yet. If a tech creates a device while offline the operation silently `catch()`es. Needs a write queue analogous to `enqueueVisionScan`. - [ ] **Dual-language strings**: UI is French-only. Roadmap item to extract to `src/i18n/` once Ops desktop i18n lands. - [ ] **Equipment type normalization**: `eqTypes` array 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/diagnostic` manually. Could fire a background probe the moment a device is scanned. --- ## 9. Related docs - [features/vision-ocr.md](vision-ocr.md) — the Gemini pipeline backing the scanner - [features/dispatch.md](dispatch.md) — where `Dispatch Job` rows are created + assigned - [features/customer-portal.md](customer-portal.md) — same magic-link pattern, 24h TTL, different token schema - [features/cpe-management.md](cpe-management.md) — GenieACS probe called by `TechDiagnosticPage` - [architecture/overview.md](../architecture/overview.md) — Traefik routes, Authentik wrapping, hub placement - [architecture/module-interactions.md](../architecture/module-interactions.md) — cross-module read/write matrix Back to [docs/features/README.md](README.md) · [docs/README.md](../README.md)