# 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: <url>"
  ↓ 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=<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

```html
<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

```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
<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)

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

---

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