gigafibre-fsm/docs/features/tech-mobile.md
louispaulb 30a867a326 fix(tech): restore Gemini-native scanner + port equipment UX into ops
The ops tech module at /ops/#/j/* had drifted from the field app in two ways:

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

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

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

Docs:

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:56:38 -04:00

20 KiB
Raw Blame History

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

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

<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 (14 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 35 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 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.vueapps/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>

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.

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

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

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

Back to docs/features/README.md · docs/README.md