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

463 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (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:
```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)