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>
This commit is contained in:
louispaulb 2026-04-22 15:56:38 -04:00
parent 7ac9a582c6
commit 30a867a326
16 changed files with 2588 additions and 754 deletions

View File

@ -10,7 +10,6 @@
"dependencies": { "dependencies": {
"@quasar/cli": "^3.0.0", "@quasar/cli": "^3.0.0",
"@quasar/extras": "^1.16.12", "@quasar/extras": "^1.16.12",
"html5-qrcode": "^2.3.8",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"quasar": "^2.16.10", "quasar": "^2.16.10",
@ -6580,12 +6579,6 @@
"node": "^14.13.1 || >=16.0.0" "node": "^14.13.1 || >=16.0.0"
} }
}, },
"node_modules/html5-qrcode": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
"license": "Apache-2.0"
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",

View File

@ -12,7 +12,6 @@
"dependencies": { "dependencies": {
"@quasar/cli": "^3.0.0", "@quasar/cli": "^3.0.0",
"@quasar/extras": "^1.16.12", "@quasar/extras": "^1.16.12",
"html5-qrcode": "^2.3.8",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"quasar": "^2.16.10", "quasar": "^2.16.10",

View File

@ -2,61 +2,43 @@ import { ref, watch } from 'vue'
import { scanBarcodes } from 'src/api/ocr' import { scanBarcodes } from 'src/api/ocr'
import { useOfflineStore } from 'src/stores/offline' import { useOfflineStore } from 'src/stores/offline'
const GEMINI_TIMEOUT_MS = 8000 const SCAN_TIMEOUT_MS = 8000
const MAX_CODES = 3
/** /**
* Hybrid barcode scanner for field techs. * Barcode scanner using device camera photo capture + Gemini Vision AI.
* *
* Three modes, all writing into the same `barcodes` array (max 3): * Strategy: Use <input type="file" capture="environment"> which triggers
* the native camera app this gives proper autofocus, tap-to-focus,
* and high-res photos. Then send to Gemini Vision for barcode extraction.
* *
* 1. `startLive(elId)` continuous camera stream via html5-qrcode. * Resilience: if Gemini doesn't answer within SCAN_TIMEOUT_MS (weak LTE),
* Instant, offline-capable, works for standard QR / Code128 / EAN. * the photo is queued in IndexedDB via the offline store and retried when
* This is the DEFAULT and fastest path. * the signal comes back. The tech gets a "scan en attente" indicator and
* * can keep working; late results are delivered via onNewCode().
* 2. `processPhoto(file)` take a picture, scan it for codes.
* Tries html5-qrcode locally first (full image + 3 horizontal strips,
* catches up to 3 barcodes per photo, offline). If nothing is found
* AND we're online, falls back to Gemini Vision which can read weird
* stuff html5-qrcode misses: damaged stickers, multi-line serials
* printed as text, unusual barcode symbologies.
* If Gemini times out on weak LTE, the photo is queued in IndexedDB
* and retried when signal returns late results come back through
* `onNewCode` via the watcher on offline.scanResults.
*
* 3. Manual entry handled by the caller, just appends via addCode().
*
* This was rewritten in 2026-04-22 after an earlier refactor replaced all
* local scanning with Gemini-only. Live camera matters for field techs:
* point-and-scan is 10× faster than take-photo-wait-for-AI on a normal QR.
* Gemini is kept as a second chance for the hard cases.
* *
* @param {object} options * @param {object} options
* @param {(code: string) => void} [options.onNewCode] fires for each * @param {(code: string) => void} [options.onNewCode] called for each
* newly added code whether it came from live, photo, queue, or manual. * newly detected code, whether the scan was synchronous or delivered
* Typically used to trigger lookup + toast. * later from the offline queue. Typically used to trigger lookup + notify.
*/ */
export function useScanner (options = {}) { export function useScanner (options = {}) {
const onNewCode = options.onNewCode || (() => {}) const onNewCode = options.onNewCode || (() => {})
const barcodes = ref([]) // [{ value, region }], max MAX_CODES const barcodes = ref([]) // Array of { value, region } — max 3
const scanning = ref(false) // true while a photo/AI call is in-flight const scanning = ref(false) // true while Gemini is processing
const live = ref(false) // true while the live camera is running
const error = ref(null) const error = ref(null)
const lastPhoto = ref(null) // thumbnail data URI const lastPhoto = ref(null) // data URI of last captured photo (thumbnail)
const photos = ref([]) // [{ url, ts, codes, queued }] const photos = ref([]) // all captured photo thumbnails
const offline = useOfflineStore() const offline = useOfflineStore()
let _html5 = null // Html5Qrcode instance for live mode
// Pick up any Gemini scans that completed while the page was unmounted // Pick up any scans that completed while the page was unmounted (e.g. tech
// (tech queued a photo, locked phone, walked out of the basement, signal // queued a photo, locked phone, walked out of the basement, signal returns).
// returned, hub replied, we stored the result in IndexedDB).
for (const result of offline.scanResults) { for (const result of offline.scanResults) {
mergeCodes(result.barcodes || [], 'queued') mergeCodes(result.barcodes || [], 'queued')
offline.consumeScanResult(result.id) offline.consumeScanResult(result.id)
} }
// Watch for sync completions during this scanner's lifetime. // Watch for sync completions during the lifetime of this scanner.
// Vue auto-disposes the watcher when the host component unmounts. // Vue auto-disposes the watcher when the host component unmounts.
watch( watch(
() => offline.scanResults.length, () => offline.scanResults.length,
@ -69,12 +51,10 @@ export function useScanner (options = {}) {
) )
function addCode (code, region) { function addCode (code, region) {
const trimmed = String(code || '').trim() if (barcodes.value.length >= 3) return false
if (!trimmed) return false if (barcodes.value.find(b => b.value === code)) return false
if (barcodes.value.length >= MAX_CODES) return false barcodes.value.push({ value: code, region })
if (barcodes.value.find(b => b.value === trimmed)) return false onNewCode(code)
barcodes.value.push({ value: trimmed, region })
onNewCode(trimmed)
return true return true
} }
@ -86,109 +66,44 @@ export function useScanner (options = {}) {
return added return added
} }
// -------------------------------------------------------------------------
// 1. Live camera — html5-qrcode continuous stream
// -------------------------------------------------------------------------
/**
* Start the live scanner in the given DOM element.
* Safe to call again after stopLive() recreates the instance.
*
* @param {string} elementId id of a div to mount the camera view into.
* Must already be in the DOM when this is called.
*/
async function startLive (elementId) {
if (live.value) return
error.value = null
try {
const { Html5Qrcode } = await import('html5-qrcode')
_html5 = new Html5Qrcode(elementId, { verbose: false })
await _html5.start(
{ facingMode: 'environment' },
{
fps: 10,
// Wider aimbox so tech doesn't have to line up perfectly.
qrbox: (w, h) => {
const side = Math.floor(Math.min(w, h) * 0.7)
return { width: side, height: Math.floor(side * 0.6) }
},
},
(decoded) => addCode(decoded, 'caméra'),
() => { /* per-frame decode miss — ignore, this is noisy by design */ },
)
live.value = true
} catch (e) {
error.value = e?.message || 'Caméra non disponible'
live.value = false
_html5 = null
}
}
async function stopLive () {
try {
if (_html5?.isScanning) await _html5.stop()
_html5?.clear()
} catch { /* ignore — teardown races are fine */ }
_html5 = null
live.value = false
}
// -------------------------------------------------------------------------
// 2. Photo scan — html5-qrcode multi-strip, Gemini fallback
// -------------------------------------------------------------------------
/** /**
* Process a photo file from camera input. * Process a photo file from camera input.
* * Resizes for AI, keeps thumbnail, sends to Gemini with an 8s timeout.
* Pass 1: html5-qrcode on the full image (cheap, offline). * On timeout/failure, the photo is queued for background retry.
* Pass 2: split into 3 horizontal strips, scan each catches stickers
* that contain multiple barcodes stacked vertically (common on
* modems: SN, MAC, GPON SN each on their own line).
* Pass 3: if still empty AND online, send to Gemini for OCR-based read.
* If Gemini times out (weak signal), queue for background retry.
*/ */
async function processPhoto (file) { async function processPhoto (file) {
if (!file) return [] if (!file) return []
error.value = null error.value = null
scanning.value = true scanning.value = true
const found = []
const photoIdx = photos.value.length
let aiImage = null let aiImage = null
const photoIdx = photos.value.length
let found = []
try { try {
// Thumbnail for the UI // Create thumbnail for display (small)
const thumbUrl = await resizeImage(file, 400) const thumbUrl = await resizeImage(file, 400)
lastPhoto.value = thumbUrl lastPhoto.value = thumbUrl
photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [], queued: false }) photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [], queued: false })
// --- Pass 1 + 2: local html5-qrcode on full image + strips --- // Create optimized image for AI — keep high res for text readability
const localCodes = await scanPhotoLocally(file) aiImage = await resizeImage(file, 1600, 0.92)
found.push(...mergeCodes(localCodes, 'photo'))
const result = await scanBarcodesWithTimeout(aiImage, SCAN_TIMEOUT_MS)
found = mergeCodes(result.barcodes || [], 'photo')
photos.value[photoIdx].codes = found
// --- Pass 3: Gemini fallback if local scan came up empty ---
if (found.length === 0) { if (found.length === 0) {
aiImage = await resizeImage(file, 1600, 0.92)
try {
const result = await scanBarcodesWithTimeout(aiImage, GEMINI_TIMEOUT_MS)
found.push(...mergeCodes(result.barcodes || [], 'IA'))
} catch (e) {
if (isRetryable(e)) {
await offline.enqueueVisionScan({ image: aiImage })
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
} else {
throw e
}
}
}
if (photos.value[photoIdx]) photos.value[photoIdx].codes = found
if (found.length === 0 && !error.value) {
error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point' error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point'
} }
} catch (e) { } catch (e) {
error.value = e?.message || 'Erreur' if (aiImage && isRetryable(e)) {
await offline.enqueueVisionScan({ image: aiImage })
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
} else {
error.value = e.message || 'Erreur'
}
} finally { } finally {
scanning.value = false scanning.value = false
} }
@ -196,69 +111,6 @@ export function useScanner (options = {}) {
return found return found
} }
/**
* Local pass: html5-qrcode on full image + 3 horizontal strips.
* Returns an array of unique decoded strings. Always safe to call
* errors at any stage are swallowed and produce an empty result.
*/
async function scanPhotoLocally (file) {
const found = new Set()
let scratch = document.getElementById('scanner-scratch')
if (!scratch) {
scratch = document.createElement('div')
scratch.id = 'scanner-scratch'
scratch.style.display = 'none'
document.body.appendChild(scratch)
}
let scanner
try {
const { Html5Qrcode } = await import('html5-qrcode')
scanner = new Html5Qrcode('scanner-scratch', { verbose: false })
// Full image first
try {
const r = await scanner.scanFileV2(file, false)
if (r?.decodedText) found.add(r.decodedText)
} catch { /* no code on full image — try strips */ }
// Split into 3 strips and scan each — only if we still have room
if (found.size < MAX_CODES) {
const img = await createImageBitmap(file)
try {
const { width, height } = img
const strips = [
{ y: 0, h: Math.floor(height / 3), label: 'haut' },
{ y: Math.floor(height / 3), h: Math.floor(height / 3), label: 'milieu' },
{ y: Math.floor(height * 2 / 3), h: height - Math.floor(height * 2 / 3), label: 'bas' },
]
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
for (const strip of strips) {
if (found.size >= MAX_CODES) break
canvas.width = width
canvas.height = strip.h
ctx.drawImage(img, 0, strip.y, width, strip.h, 0, 0, width, strip.h)
try {
const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.9))
const stripFile = new File([blob], 'strip.jpg', { type: 'image/jpeg' })
const r = await scanner.scanFileV2(stripFile, false)
if (r?.decodedText) found.add(r.decodedText)
} catch { /* strip miss — continue */ }
}
} finally {
img.close()
}
}
} catch { /* library load / instantiation failure — caller falls back to Gemini */ }
finally {
try { scanner?.clear() } catch { /* ignore */ }
}
return Array.from(found)
}
async function scanBarcodesWithTimeout (image, ms) { async function scanBarcodesWithTimeout (image, ms) {
return await Promise.race([ return await Promise.race([
scanBarcodes(image), scanBarcodes(image),
@ -278,11 +130,9 @@ export function useScanner (options = {}) {
|| e?.name === 'TypeError' // fetch throws TypeError on network error || e?.name === 'TypeError' // fetch throws TypeError on network error
} }
// ------------------------------------------------------------------------- /**
// Utilities * Resize an image file to a max dimension, return as base64 data URI.
// ------------------------------------------------------------------------- */
/** Resize an image file to a max dimension, return as base64 data URI. */
function resizeImage (file, maxDim, quality = 0.85) { function resizeImage (file, maxDim, quality = 0.85) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image() const img = new Image()
@ -316,15 +166,7 @@ export function useScanner (options = {}) {
} }
return { return {
// state barcodes, scanning, error, lastPhoto, photos,
barcodes, scanning, live, error, lastPhoto, photos, processPhoto, removeBarcode, clearBarcodes,
// live camera
startLive, stopLive,
// photo scan
processPhoto,
// shared
addCode, removeBarcode, clearBarcodes,
// constants
MAX_CODES,
} }
} }

View File

@ -17,99 +17,55 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
<!-- Mode tabs: Caméra / Photo IA / Manuel --> <!-- Camera capture button -->
<q-tabs v-model="mode" dense no-caps active-color="primary" indicator-color="primary" align="justify" <div class="text-center">
class="q-mb-md" @update:model-value="onModeChange">
<q-tab name="live" icon="videocam" label="Caméra" />
<q-tab name="photo" icon="photo_camera" label="Photo" />
<q-tab name="manual" icon="keyboard" label="Manuel" />
</q-tabs>
<!-- ==== Live camera mode (default, fastest) ==== -->
<div v-show="mode === 'live'" class="live-wrap">
<div id="live-reader" class="live-reader" />
<div class="text-center q-mt-sm">
<q-btn v-if="!scanner.live.value" color="primary" icon="play_arrow"
label="Démarrer la caméra" @click="startLive" unelevated />
<q-btn v-else color="negative" icon="stop" label="Arrêter" @click="scanner.stopLive()" outline />
</div>
<div v-if="scanner.live.value" class="text-caption text-center text-grey q-mt-xs">
Cadrez un code-barres détection automatique
</div>
</div>
<!-- ==== Photo mode (native camera + local scan + Gemini fallback) ==== -->
<div v-show="mode === 'photo'" class="text-center">
<q-btn <q-btn
color="primary" icon="photo_camera" label="Prendre une photo" color="primary" icon="photo_camera" label="Scanner"
size="lg" rounded unelevated size="lg" rounded unelevated
@click="takePhoto" @click="takePhoto"
:loading="scanner.scanning.value" :loading="scanner.scanning.value"
class="q-px-xl" class="q-px-xl"
/> />
<input ref="cameraInput" type="file" accept="image/*" capture="environment" class="hidden" @change="onPhoto" /> <input ref="cameraInput" type="file" accept="image/*" capture="environment" class="hidden" @change="onPhoto" />
<div class="text-caption text-grey q-mt-sm"> </div>
Jusqu'à 3 codes par photo · IA activée si la lecture locale échoue
</div>
<!-- Pending scan indicator (signal faible) --> <!-- Pending scan indicator (signal faible) -->
<div v-if="offline.pendingVisionCount > 0" class="text-center q-mt-sm"> <div v-if="offline.pendingVisionCount > 0" class="text-center q-mt-sm">
<q-chip icon="wifi_tethering_off" color="orange" text-color="white" dense clickable <q-chip icon="wifi_tethering_off" color="orange" text-color="white" dense clickable @click="offline.syncVisionQueue()">
@click="offline.syncVisionQueue()"> {{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }} en attente · toucher pour réessayer
{{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }} </q-chip>
en attente · toucher pour réessayer </div>
</q-chip>
</div>
<!-- Last captured photo (thumbnail) --> <!-- Last captured photo (thumbnail) -->
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md"> <div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" /> <img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
<div v-if="scanner.scanning.value" class="preview-overlay"> <div v-if="scanner.scanning.value" class="preview-overlay">
<q-spinner-dots size="32px" color="white" /> <q-spinner-dots size="32px" color="white" />
<div class="text-white text-caption q-mt-xs">Analyse...</div> <div class="text-white text-caption q-mt-xs">Analyse Gemini...</div>
</div>
</div>
<!-- Photo history (small thumbnails) -->
<div v-if="scanner.photos.value.length > 1" class="q-mt-md">
<div class="text-caption text-grey q-mb-xs">Photos capturées</div>
<div class="row q-gutter-xs">
<div v-for="(p, i) in scanner.photos.value" :key="i" class="photo-thumb" @click="viewPhoto(p)">
<img :src="p.url" />
<q-badge v-if="p.codes.length" color="green" floating :label="p.codes.length" />
</div>
</div>
</div> </div>
</div> </div>
<!-- ==== Manual entry ==== --> <!-- Error / status -->
<div v-show="mode === 'manual'">
<q-input v-model="manualCode" label="Saisie manuelle SN / MAC / code-barres"
outlined dense autofocus @keyup.enter="addManual">
<template v-slot:prepend><q-icon name="keyboard" /></template>
<template v-slot:append>
<q-btn flat dense icon="add" :disable="!manualCode.trim()" @click="addManual" />
</template>
</q-input>
</div>
<!-- ==== Error / status (all modes) ==== -->
<div v-if="scanner.error.value" class="text-caption text-center q-mt-sm text-negative"> <div v-if="scanner.error.value" class="text-caption text-center q-mt-sm text-negative">
{{ scanner.error.value }} {{ scanner.error.value }}
</div> </div>
<!-- ==== Scanned barcodes (shared across all modes) ==== --> <!-- Manual entry -->
<q-input v-model="manualCode" label="Saisie manuelle SN / MAC" outlined dense class="q-mt-md"
@keyup.enter="addManual">
<template v-slot:append>
<q-btn flat dense icon="add" @click="addManual" :disable="!manualCode.trim()" />
</template>
</q-input>
<!-- Scanned barcodes -->
<div v-if="scanner.barcodes.value.length > 0" class="q-mt-md"> <div v-if="scanner.barcodes.value.length > 0" class="q-mt-md">
<div class="row items-center q-mb-xs"> <div class="text-subtitle2 q-mb-xs">Codes détectés ({{ scanner.barcodes.value.length }}/3)</div>
<div class="text-subtitle2 col">Codes détectés ({{ scanner.barcodes.value.length }}/{{ scanner.MAX_CODES }})</div>
<q-btn flat dense size="sm" icon="clear_all" label="Effacer" @click="scanner.clearBarcodes()" />
</div>
<q-card v-for="bc in scanner.barcodes.value" :key="bc.value" class="q-mb-sm"> <q-card v-for="bc in scanner.barcodes.value" :key="bc.value" class="q-mb-sm">
<q-card-section class="q-py-sm row items-center no-wrap"> <q-card-section class="q-py-sm row items-center no-wrap">
<q-icon name="qr_code" class="q-mr-sm" color="primary" /> <q-icon name="qr_code" class="q-mr-sm" color="primary" />
<div class="col"> <div class="col">
<div class="text-subtitle2 mono">{{ bc.value }}</div> <div class="text-subtitle2 mono">{{ bc.value }}</div>
<div class="text-caption text-grey">{{ bc.region }}</div>
</div> </div>
<q-btn flat dense icon="search" @click="lookupDevice(bc.value)" :loading="lookingUp === bc.value" /> <q-btn flat dense icon="search" @click="lookupDevice(bc.value)" :loading="lookingUp === bc.value" />
<q-btn flat dense icon="close" color="negative" @click="scanner.removeBarcode(bc.value)" /> <q-btn flat dense icon="close" color="negative" @click="scanner.removeBarcode(bc.value)" />
@ -130,7 +86,7 @@
<q-btn flat dense size="sm" color="orange" label="Lier à un service" icon="link" <q-btn flat dense size="sm" color="orange" label="Lier à un service" icon="link"
@click="openLinkDialog(bc.value, lookupResults[bc.value].equipment)" /> @click="openLinkDialog(bc.value, lookupResults[bc.value].equipment)" />
</div> </div>
<div v-else-if="lookupResults[bc.value].equipment.service_location" class="text-caption text-green q-mt-xs"> <div v-else class="text-caption text-green q-mt-xs">
<q-icon name="check_circle" size="xs" class="q-mr-xs" /> <q-icon name="check_circle" size="xs" class="q-mr-xs" />
{{ lookupResults[bc.value].equipment.service_location }} {{ lookupResults[bc.value].equipment.service_location }}
</div> </div>
@ -146,6 +102,17 @@
</q-card> </q-card>
</div> </div>
<!-- Photo history (small thumbnails) -->
<div v-if="scanner.photos.value.length > 1" class="q-mt-md">
<div class="text-caption text-grey q-mb-xs">Photos capturées</div>
<div class="row q-gutter-xs">
<div v-for="(p, i) in scanner.photos.value" :key="i" class="photo-thumb" @click="viewPhoto(p)">
<img :src="p.url" />
<q-badge v-if="p.codes.length" color="green" floating :label="p.codes.length" />
</div>
</div>
</div>
<!-- Link all to account (manual, when no job context) --> <!-- Link all to account (manual, when no job context) -->
<div v-if="scanner.barcodes.value.length > 0 && !jobContext && hasUnlinked" class="q-mt-sm"> <div v-if="scanner.barcodes.value.length > 0 && !jobContext && hasUnlinked" class="q-mt-sm">
<q-btn color="orange" icon="link" label="Lier les équipements à un service" <q-btn color="orange" icon="link" label="Lier les équipements à un service"
@ -235,7 +202,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onBeforeUnmount, nextTick } from 'vue' import { ref, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useScanner } from 'src/composables/useScanner' import { useScanner } from 'src/composables/useScanner'
import { listDocs, createDoc, updateDoc } from 'src/api/erp' import { listDocs, createDoc, updateDoc } from 'src/api/erp'
@ -251,8 +218,6 @@ const scanner = useScanner({
}, },
}) })
// Default to live camera that's what techs use 90% of the time.
const mode = ref('live')
const cameraInput = ref(null) const cameraInput = ref(null)
const manualCode = ref('') const manualCode = ref('')
const lookingUp = ref(null) const lookingUp = ref(null)
@ -293,30 +258,10 @@ const hasUnlinked = computed(() =>
}) })
) )
// --- Mode switch: stop the camera when leaving Live, pause auto-restart --- // --- Camera ---
function onModeChange (newMode) {
if (newMode !== 'live' && scanner.live.value) scanner.stopLive()
}
// Make sure we don't leak the camera when the page unmounts.
onBeforeUnmount(() => {
if (scanner.live.value) scanner.stopLive()
})
// --- Live camera ---
async function startLive () {
// The reader element is inside v-show it's in the DOM from first render.
// nextTick just in case something upstream hid it.
await nextTick()
await scanner.startLive('live-reader')
}
// --- Photo camera ---
function takePhoto () { function takePhoto () {
// Reset the input so the same file triggers change // Reset the input so same file triggers change
if (cameraInput.value) cameraInput.value.value = '' if (cameraInput.value) cameraInput.value.value = ''
cameraInput.value?.click() cameraInput.value?.click()
} }
@ -340,16 +285,15 @@ function viewPhoto (photo) {
function addManual () { function addManual () {
const code = manualCode.value.trim() const code = manualCode.value.trim()
if (!code) return if (!code) return
if (scanner.barcodes.value.length >= scanner.MAX_CODES) { if (scanner.barcodes.value.length >= 3) {
Notify.create({ type: 'warning', message: `Maximum ${scanner.MAX_CODES} codes` }) Notify.create({ type: 'warning', message: 'Maximum 3 codes' })
return return
} }
if (scanner.addCode(code, 'manuel')) { if (!scanner.barcodes.value.find(b => b.value === code)) {
manualCode.value = '' scanner.barcodes.value.push({ value: code, region: 'manuel' })
} else { lookupDevice(code)
Notify.create({ type: 'info', message: 'Code déjà ajouté' })
manualCode.value = ''
} }
manualCode.value = ''
} }
// --- Device lookup --- // --- Device lookup ---
@ -552,25 +496,6 @@ async function linkDeviceToService () {
padding-bottom: 16px !important; padding-bottom: 16px !important;
} }
.live-wrap {
max-width: 420px;
margin: 0 auto;
}
.live-reader {
width: 100%;
min-height: 280px;
border-radius: 12px;
overflow: hidden;
background: #000;
// html5-qrcode injects a <video> make it fill the container
:deep(video) {
width: 100% !important;
height: auto !important;
display: block;
}
}
.photo-preview { .photo-preview {
position: relative; position: relative;
text-align: center; text-align: center;
@ -607,8 +532,4 @@ async function linkDeviceToService () {
object-fit: cover; object-fit: cover;
} }
} }
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
</style> </style>

View File

@ -12,7 +12,6 @@
"@twilio/voice-sdk": "^2.18.1", "@twilio/voice-sdk": "^2.18.1",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"cytoscape": "^3.33.2", "cytoscape": "^3.33.2",
"html5-qrcode": "^2.3.8",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"lucide-vue-next": "^1.0.0", "lucide-vue-next": "^1.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
@ -5894,12 +5893,6 @@
"node": "^14.13.1 || >=16.0.0" "node": "^14.13.1 || >=16.0.0"
} }
}, },
"node_modules/html5-qrcode": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
"license": "Apache-2.0"
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",

View File

@ -14,7 +14,6 @@
"@twilio/voice-sdk": "^2.18.1", "@twilio/voice-sdk": "^2.18.1",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"cytoscape": "^3.33.2", "cytoscape": "^3.33.2",
"html5-qrcode": "^2.3.8",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"lucide-vue-next": "^1.0.0", "lucide-vue-next": "^1.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",

View File

@ -1,45 +1,37 @@
/** /**
* useScanner hybrid camera + barcode composable for field techs. * useScanner camera-capture + Gemini Vision composable.
* *
* Three capture modes, one shared `barcodes` array: * Two capture modes, one pipeline:
* - processPhoto(file) barcode/serial extraction (ScanPage, /j)
* - scanEquipmentLabel(file) structured ONT/ONU label (equipment
* linking, ClientDetailPage photos)
* *
* 1. Live camera (startLive / stopLive) * Both resize the photo twice:
* html5-qrcode continuous stream. INSTANT, OFFLINE, and the fastest * - 400px for the on-screen thumbnail
* path for normal QR codes on modem stickers. This is the default on * - 1600px @ q=0.92 for Gemini (text readability > filesize)
* the /j/scan page techs point, scan, done.
* *
* 2. Photo scan (processPhoto) * Resilience (barcode mode only):
* Take a picture with the native camera, then: * If Gemini doesn't answer within SCAN_TIMEOUT_MS (weak LTE, basement,
* Pass 1 html5-qrcode on the full image (cheap, offline) * service cold-start), the photo is queued in IndexedDB via the offline
* Pass 2 split into 3 horizontal strips, scan each catches up * store and retried in the background. The tech sees a "scan en attente"
* to 3 stacked barcodes on a single sticker (SN + MAC + * chip, keeps scanning the next equipment, and the late result is pushed
* GPON SN is a common layout) * back into `barcodes` via a reactive watcher on `offline.scanResults`.
* Pass 3 if still empty AND online, fall back to Gemini Vision
* which can read damaged stickers, multi-line text-only
* serials, and unusual symbologies
* If Gemini times out on weak LTE, photo is queued in IndexedDB
* via the offline store. Late results come back through
* `onNewCode` via the watcher on offline.scanResults.
* *
* 3. Equipment label scan (scanEquipmentLabel) * Equipment-label mode does NOT queue it's typically invoked on a desktop
* Dedicated Gemini-only path for structured ONT/ONU label OCR. * or strong wifi (indoor install, office) where the extra complexity of
* Used from ClientDetailPage/equipment-linking flows where the caller * background retry isn't worth it, and callers want a synchronous answer
* wants a sync answer ({brand, model, serial, mac, gpon_sn, ...}) * (to pre-fill an equipment form).
* not resilient, no queue, desktop/wifi only.
* *
* History: this file originally lived in apps/field. In 2026-04-22 the * Merged from apps/ops/src/composables/useScanner.js (which had the
* field scanner was briefly replaced with Gemini-only capture, losing * equipment-label branch) and apps/field/src/composables/useScanner.js
* the live camera. This version restores live + multi-strip as the fast * (which had the resilient timeout + offline queue). See
* primary path and keeps Gemini as a second-chance fallback. * docs/ARCHITECTURE.md §"Legacy Retirement Plan" field is being folded
* * into ops at /j and must not lose offline capability in the process.
* See docs/architecture/overview.md §"Legacy Retirement Plan" apps/field
* is being folded into apps/ops at /j and must not lose offline capability
* or live scanning in the process.
* *
* @param {object} options * @param {object} options
* @param {(code: string) => void} [options.onNewCode] fires for each * @param {(code: string) => void} [options.onNewCode] fires for each
* newly added code. Fires for live scans, photo scans, queued retries, * newly detected code, whether the scan was synchronous OR delivered
* and equipment-label scans. Typical use: trigger an ERPNext lookup * later from the offline queue. Typical use: trigger an ERPNext lookup
* and Quasar notify. * and Quasar notify.
*/ */
@ -47,25 +39,27 @@ import { ref, watch } from 'vue'
import { scanBarcodes, scanEquipmentLabel as apiScanEquipmentLabel } from 'src/api/ocr' import { scanBarcodes, scanEquipmentLabel as apiScanEquipmentLabel } from 'src/api/ocr'
import { useOfflineStore } from 'src/stores/offline' import { useOfflineStore } from 'src/stores/offline'
const GEMINI_TIMEOUT_MS = 8000 const SCAN_TIMEOUT_MS = 8000
const MAX_BARCODES = 5 // equipment labels have more identifiers than field cap of 3
export function useScanner (options = {}) { export function useScanner (options = {}) {
const onNewCode = options.onNewCode || (() => {}) const onNewCode = options.onNewCode || (() => {})
const barcodes = ref([]) // { value, region }[] — max MAX_BARCODES const barcodes = ref([]) // { value, region }[] — max MAX_BARCODES
const scanning = ref(false) // true while a photo/AI call is in flight const scanning = ref(false) // true while a Gemini call is in flight
const live = ref(false) // true while the live camera is running
const error = ref(null) const error = ref(null)
const lastPhoto = ref(null) // data URI of last thumbnail (400px) const lastPhoto = ref(null) // data URI of last thumbnail (400px)
const photos = ref([]) // { url, ts, codes, queued }[] — full history const photos = ref([]) // { url, ts, codes, queued }[] — full history
const offline = useOfflineStore() // Field's default cap was 3 (phone screen estate); ops historically
let _html5 = null // Html5Qrcode live instance // allowed 5 (equipment labels have more identifiers). Keep 5 here
// since equipment-label mode is an ops-only feature.
const MAX_BARCODES = 5
// Pick up any Gemini scans that completed while the composable was unmounted const offline = useOfflineStore()
// (tech queued a photo in the basement, phone locked, signal returned while
// the page was gone, now they reopen ScanPage). // Pick up any scans that completed while the composable was unmounted
// (e.g. tech queued a photo in the basement, phone locked, signal
// returned while the page was gone, now they reopen ScanPage).
for (const result of offline.scanResults) { for (const result of offline.scanResults) {
mergeCodes(result.barcodes || [], 'queued') mergeCodes(result.barcodes || [], 'queued')
offline.consumeScanResult(result.id) offline.consumeScanResult(result.id)
@ -84,12 +78,10 @@ export function useScanner (options = {}) {
) )
function addCode (code, region) { function addCode (code, region) {
const trimmed = String(code || '').trim()
if (!trimmed) return false
if (barcodes.value.length >= MAX_BARCODES) return false if (barcodes.value.length >= MAX_BARCODES) return false
if (barcodes.value.find(b => b.value === trimmed)) return false if (barcodes.value.find(b => b.value === code)) return false
barcodes.value.push({ value: trimmed, region }) barcodes.value.push({ value: code, region })
onNewCode(trimmed) onNewCode(code)
return true return true
} }
@ -101,105 +93,42 @@ export function useScanner (options = {}) {
return added return added
} }
// -------------------------------------------------------------------------
// 1. Live camera
// -------------------------------------------------------------------------
/**
* Start the live scanner in the given DOM element.
* Safe to call again after stopLive(). Element must exist in the DOM.
*/
async function startLive (elementId) {
if (live.value) return
error.value = null
try {
const { Html5Qrcode } = await import('html5-qrcode')
_html5 = new Html5Qrcode(elementId, { verbose: false })
await _html5.start(
{ facingMode: 'environment' },
{
fps: 10,
// Wider aimbox so tech doesn't have to line up perfectly.
qrbox: (w, h) => {
const side = Math.floor(Math.min(w, h) * 0.7)
return { width: side, height: Math.floor(side * 0.6) }
},
},
(decoded) => addCode(decoded, 'caméra'),
() => { /* per-frame decode miss — ignore, this is noisy by design */ },
)
live.value = true
} catch (e) {
error.value = e?.message || 'Caméra non disponible'
live.value = false
_html5 = null
}
}
async function stopLive () {
try {
if (_html5?.isScanning) await _html5.stop()
_html5?.clear()
} catch { /* ignore — teardown races are fine */ }
_html5 = null
live.value = false
}
// -------------------------------------------------------------------------
// 2. Photo scan — html5-qrcode local, Gemini fallback, offline queue
// -------------------------------------------------------------------------
/** /**
* Process a photo for generic barcode/serial extraction. * Process a photo for generic barcode/serial extraction.
* * Resilient: on timeout/network error the photo is queued for retry.
* Three-pass strategy:
* 1. html5-qrcode on full image (offline, fast)
* 2. html5-qrcode on 3 horizontal strips (multi-barcode)
* 3. Gemini Vision fallback if local found nothing AND we're online
*
* Resilient: if Gemini times out the photo is queued for retry.
*/ */
async function processPhoto (file) { async function processPhoto (file) {
if (!file) return [] if (!file) return []
error.value = null error.value = null
scanning.value = true scanning.value = true
const found = []
const photoIdx = photos.value.length
let aiImage = null let aiImage = null
const photoIdx = photos.value.length
let found = []
try { try {
const thumbUrl = await resizeImage(file, 400) const thumbUrl = await resizeImage(file, 400)
lastPhoto.value = thumbUrl lastPhoto.value = thumbUrl
photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [], queued: false }) photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [], queued: false })
// --- Pass 1 + 2: local html5-qrcode on full image + strips --- // Keep high-res for text readability (small serial fonts).
const localCodes = await scanPhotoLocally(file) aiImage = await resizeImage(file, 1600, 0.92)
found.push(...mergeCodes(localCodes, 'photo'))
const result = await scanBarcodesWithTimeout(aiImage, SCAN_TIMEOUT_MS)
found = mergeCodes(result.barcodes || [], 'photo')
photos.value[photoIdx].codes = found
// --- Pass 3: Gemini fallback if local scan came up empty ---
if (found.length === 0) { if (found.length === 0) {
aiImage = await resizeImage(file, 1600, 0.92)
try {
const result = await scanBarcodesWithTimeout(aiImage, GEMINI_TIMEOUT_MS)
found.push(...mergeCodes(result.barcodes || [], 'IA'))
} catch (e) {
if (isRetryable(e)) {
await offline.enqueueVisionScan({ image: aiImage })
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
} else {
throw e
}
}
}
if (photos.value[photoIdx]) photos.value[photoIdx].codes = found
if (found.length === 0 && !error.value) {
error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point' error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point'
} }
} catch (e) { } catch (e) {
error.value = e?.message || 'Erreur' if (aiImage && isRetryable(e)) {
await offline.enqueueVisionScan({ image: aiImage })
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
} else {
error.value = e.message || 'Erreur'
}
} finally { } finally {
scanning.value = false scanning.value = false
} }
@ -207,72 +136,6 @@ export function useScanner (options = {}) {
return found return found
} }
/**
* Local pass: html5-qrcode on full image + 3 horizontal strips.
* Returns unique decoded strings. All failures swallowed empty array.
*/
async function scanPhotoLocally (file) {
const found = new Set()
let scratch = document.getElementById('scanner-scratch')
if (!scratch) {
scratch = document.createElement('div')
scratch.id = 'scanner-scratch'
scratch.style.display = 'none'
document.body.appendChild(scratch)
}
let scanner
try {
const { Html5Qrcode } = await import('html5-qrcode')
scanner = new Html5Qrcode('scanner-scratch', { verbose: false })
// Full image first
try {
const r = await scanner.scanFileV2(file, false)
if (r?.decodedText) found.add(r.decodedText)
} catch { /* no code on full image — try strips */ }
// Split into 3 horizontal strips — catches stacked barcodes
if (found.size < MAX_BARCODES) {
const img = await createImageBitmap(file)
try {
const { width, height } = img
const strips = [
{ y: 0, h: Math.floor(height / 3), label: 'haut' },
{ y: Math.floor(height / 3), h: Math.floor(height / 3), label: 'milieu' },
{ y: Math.floor(height * 2 / 3), h: height - Math.floor(height * 2 / 3), label: 'bas' },
]
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
for (const strip of strips) {
if (found.size >= MAX_BARCODES) break
canvas.width = width
canvas.height = strip.h
ctx.drawImage(img, 0, strip.y, width, strip.h, 0, 0, width, strip.h)
try {
const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.9))
const stripFile = new File([blob], 'strip.jpg', { type: 'image/jpeg' })
const r = await scanner.scanFileV2(stripFile, false)
if (r?.decodedText) found.add(r.decodedText)
} catch { /* strip miss — continue */ }
}
} finally {
img.close()
}
}
} catch { /* lib load / instantiation failure — caller falls back to Gemini */ }
finally {
try { scanner?.clear() } catch { /* ignore */ }
}
return Array.from(found)
}
// -------------------------------------------------------------------------
// 3. Equipment label scan (ops-only, desktop/wifi, structured Gemini output)
// -------------------------------------------------------------------------
/** /**
* Process a photo for structured equipment-label extraction. * Process a photo for structured equipment-label extraction.
* *
@ -312,10 +175,7 @@ export function useScanner (options = {}) {
} }
} }
// ------------------------------------------------------------------------- /** Race scanBarcodes against a timeout. Used only for barcode mode. */
// Utilities
// -------------------------------------------------------------------------
async function scanBarcodesWithTimeout (image, ms) { async function scanBarcodesWithTimeout (image, ms) {
return await Promise.race([ return await Promise.race([
scanBarcodes(image), scanBarcodes(image),
@ -326,6 +186,7 @@ export function useScanner (options = {}) {
]) ])
} }
/** Retryable = worth queueing in IndexedDB for later. */
function isRetryable (e) { function isRetryable (e) {
const msg = (e?.message || '').toLowerCase() const msg = (e?.message || '').toLowerCase()
return msg.includes('scantimeout') return msg.includes('scantimeout')
@ -335,6 +196,7 @@ export function useScanner (options = {}) {
|| e?.name === 'TypeError' // fetch throws TypeError on network error || e?.name === 'TypeError' // fetch throws TypeError on network error
} }
/** Resize a File to a max dimension, return as base64 data URI. */
function resizeImage (file, maxDim, quality = 0.85) { function resizeImage (file, maxDim, quality = 0.85) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image() const img = new Image()
@ -368,15 +230,7 @@ export function useScanner (options = {}) {
} }
return { return {
// state barcodes, scanning, error, lastPhoto, photos,
barcodes, scanning, live, error, lastPhoto, photos, processPhoto, scanEquipmentLabel, removeBarcode, clearBarcodes,
// live camera
startLive, stopLive,
// photo scans
processPhoto, scanEquipmentLabel,
// shared
addCode, removeBarcode, clearBarcodes,
// constants
MAX_BARCODES,
} }
} }

View File

@ -37,16 +37,31 @@
<!-- Content --> <!-- Content -->
<div class="job-content q-pa-md"> <div class="job-content q-pa-md">
<!-- Info -->
<!-- Info card -->
<q-card flat bordered class="q-mb-md"> <q-card flat bordered class="q-mb-md">
<q-card-section class="q-pb-none"><div class="text-overline text-grey-6">INFORMATIONS</div></q-card-section> <q-card-section class="q-pb-none"><div class="text-overline text-grey-6">INFORMATIONS</div></q-card-section>
<q-card-section> <q-card-section>
<q-input v-model="job.subject" label="Sujet" outlined dense class="q-mb-sm" @blur="saveField('subject', job.subject)" /> <q-input v-model="job.subject" label="Sujet" outlined dense class="q-mb-sm"
@blur="saveField('subject', job.subject)" />
<div class="row q-gutter-sm q-mb-sm"> <div class="row q-gutter-sm q-mb-sm">
<q-input v-model="job.scheduled_time" label="Heure" type="time" outlined dense class="col" @blur="saveField('scheduled_time', job.scheduled_time)" /> <q-select v-model="job.job_type" :options="jobTypes" label="Type" outlined dense
<q-input v-model="job.duration_h" label="Duree (h)" type="number" step="0.5" min="0.5" outlined dense class="col" @blur="saveField('duration_h', parseFloat(job.duration_h) || 1)" /> class="col" emit-value map-options @update:model-value="saveField('job_type', $event)" />
<q-select v-model="job.priority" :options="priorities" label="Priorité" outlined dense
class="col" emit-value map-options @update:model-value="saveField('priority', $event)" />
</div> </div>
<q-input v-model="job.description" label="Notes / Description" type="textarea" outlined dense autogrow rows="2" @blur="saveField('description', job.description)" />
<div class="row q-gutter-sm q-mb-sm">
<q-input v-model="job.scheduled_time" label="Heure" type="time" outlined dense class="col"
@blur="saveField('scheduled_time', job.scheduled_time)" />
<q-input v-model="displayDuration" label="Durée (h)" type="number" step="0.5" min="0.5" max="12"
outlined dense class="col"
@blur="saveField('duration_h', parseFloat(displayDuration) || 1)" />
</div>
<q-input v-model="job.description" label="Notes / Description" type="textarea" outlined dense
autogrow rows="2" @blur="saveField('description', job.description)" />
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -58,11 +73,15 @@
<q-icon name="person" color="grey" class="q-mr-sm" /> <q-icon name="person" color="grey" class="q-mr-sm" />
<span class="text-body2">{{ job.customer_name }}</span> <span class="text-body2">{{ job.customer_name }}</span>
</div> </div>
<div v-if="job.service_location_name" class="row items-center q-mb-sm"> <div v-if="locationAddress" class="row items-center q-mb-sm">
<q-icon name="place" color="grey" class="q-mr-sm" /> <q-icon name="place" color="grey" class="q-mr-sm" />
<span class="text-body2">{{ job.service_location_name }}</span> <span class="text-body2">{{ locationAddress }}</span>
<q-space /> <q-space />
<q-btn flat dense round icon="navigation" color="primary" @click="openGps" /> <q-btn flat dense round icon="navigation" color="primary" @click="openGps" title="Naviguer" />
</div>
<div v-if="locationDetail?.contact_name" class="text-caption text-grey">
Contact: {{ locationDetail.contact_name }}
<span v-if="locationDetail.contact_phone"> {{ locationDetail.contact_phone }}</span>
</div> </div>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -70,22 +89,111 @@
<!-- Equipment --> <!-- Equipment -->
<q-card flat bordered class="q-mb-md"> <q-card flat bordered class="q-mb-md">
<q-card-section class="q-pb-none row items-center"> <q-card-section class="q-pb-none row items-center">
<div class="text-overline text-grey-6 col">EQUIPEMENTS ({{ equipment.length }})</div> <div class="text-overline text-grey-6 col">ÉQUIPEMENTS ({{ equipment.length }})</div>
<q-btn flat dense size="sm" icon="qr_code_scanner" color="primary" label="Scanner" @click="goScan" /> <q-btn flat dense size="sm" icon="add" color="primary" label="Ajouter" @click="addEquipmentMenu = true" />
</q-card-section> </q-card-section>
<q-list v-if="equipment.length" separator> <q-card-section v-if="loadingEquip" class="text-center">
<q-item v-for="eq in equipment" :key="eq.name"> <q-spinner size="sm" />
</q-card-section>
<q-list v-else-if="equipment.length" separator>
<q-item v-for="eq in equipment" :key="eq.name" clickable
@click="$router.push({ name: 'tech-device', params: { serial: eq.serial_number } })">
<q-item-section avatar> <q-item-section avatar>
<q-icon :name="eq.equipment_type === 'ONT' ? 'settings_input_hdmi' : eq.equipment_type === 'Routeur' ? 'wifi' : 'memory'" :color="eq.status === 'Actif' ? 'green' : 'grey'" /> <q-icon :name="eqIcon(eq.equipment_type)" :color="eqStatusColor(eq.status)" />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label>{{ eq.equipment_type }} {{ eq.brand }} {{ eq.model }}</q-item-label> <q-item-label>{{ eq.equipment_type }} {{ eq.brand }} {{ eq.model }}</q-item-label>
<q-item-label caption style="font-family:monospace">{{ eq.serial_number }}</q-item-label> <q-item-label caption class="mono">{{ eq.serial_number }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-badge :color="eqStatusColor(eq.status)" :label="eq.status || '—'" />
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
<q-card-section v-else class="text-center text-grey text-caption">Aucun equipement lie</q-card-section> <q-card-section v-else class="text-center text-grey text-caption">
Aucun équipement lié
</q-card-section>
</q-card> </q-card>
<!-- Add equipment bottom-sheet -->
<q-dialog v-model="addEquipmentMenu" position="bottom">
<q-card style="width: 100%; max-width: 400px">
<q-card-section class="text-h6">Ajouter un équipement</q-card-section>
<q-list>
<q-item clickable v-close-popup @click="goScan">
<q-item-section avatar><q-icon name="qr_code_scanner" color="primary" /></q-item-section>
<q-item-section>
<q-item-label>Scanner un code-barres / QR</q-item-label>
<q-item-label caption>Utiliser la caméra pour détecter un SN ou MAC</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="searchEquipDialog = true">
<q-item-section avatar><q-icon name="search" color="orange" /></q-item-section>
<q-item-section>
<q-item-label>Rechercher un équipement existant</q-item-label>
<q-item-label caption>Par numéro de série ou MAC</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="createEquipDialog = true">
<q-item-section avatar><q-icon name="add_circle" color="green" /></q-item-section>
<q-item-section>
<q-item-label>Créer un nouvel équipement</q-item-label>
<q-item-label caption>Saisir manuellement les informations</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card>
</q-dialog>
<!-- Search existing equipment -->
<q-dialog v-model="searchEquipDialog">
<q-card style="min-width: 340px">
<q-card-section class="text-h6">Rechercher un équipement</q-card-section>
<q-card-section>
<q-input v-model="eqSearchText" label="Numéro de série ou MAC" outlined dense autofocus
@keyup.enter="searchEquipment" debounce="400" @update:model-value="searchEquipment">
<template v-slot:append><q-icon name="search" /></template>
</q-input>
<q-list v-if="eqSearchResults.length" bordered separator class="q-mt-sm" style="max-height: 250px; overflow-y: auto">
<q-item v-for="eq in eqSearchResults" :key="eq.name" clickable @click="linkEquipToJob(eq)">
<q-item-section>
<q-item-label>{{ eq.equipment_type }} {{ eq.brand }} {{ eq.model }}</q-item-label>
<q-item-label caption class="mono">SN: {{ eq.serial_number }}</q-item-label>
<q-item-label caption v-if="eq.customer_name">Client: {{ eq.customer_name }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="link" color="primary" />
</q-item-section>
</q-item>
</q-list>
<div v-if="eqSearchText && !eqSearchResults.length && !eqSearching" class="text-caption text-grey q-mt-sm text-center">
Aucun résultat
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Fermer" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Create new equipment -->
<q-dialog v-model="createEquipDialog">
<q-card style="min-width: 340px">
<q-card-section class="text-h6">Nouvel équipement</q-card-section>
<q-card-section>
<q-input v-model="newEquip.serial_number" label="Numéro de série" outlined dense class="q-mb-sm" autofocus />
<q-select v-model="newEquip.equipment_type" :options="eqTypes" label="Type" outlined dense class="q-mb-sm" />
<q-input v-model="newEquip.brand" label="Marque" outlined dense class="q-mb-sm" />
<q-input v-model="newEquip.model" label="Modèle" outlined dense class="q-mb-sm" />
<q-input v-model="newEquip.mac_address" label="MAC (optionnel)" outlined dense class="q-mb-sm" />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn color="primary" label="Créer & Lier" :loading="creatingEquip" @click="createAndLinkEquip" />
</q-card-actions>
</q-card>
</q-dialog>
</div> </div>
</template> </template>
</q-page> </q-page>
@ -95,73 +203,226 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Notify } from 'quasar' import { Notify } from 'quasar'
import { getDoc, listDocs, createDoc, updateDoc } from 'src/api/erp'
import { BASE_URL } from 'src/config/erpnext' import { BASE_URL } from 'src/config/erpnext'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const jobName = computed(() => route.params.name)
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const job = ref(null) const job = ref(null)
const locationDetail = ref(null)
const equipment = ref([]) const equipment = ref([])
const loadingEquip = ref(false)
const jobName = computed(() => route.params.name) // Add-equipment dialog state
const addEquipmentMenu = ref(false)
const searchEquipDialog = ref(false)
const createEquipDialog = ref(false)
const eqSearchText = ref('')
const eqSearchResults = ref([])
const eqSearching = ref(false)
const creatingEquip = ref(false)
const eqTypes = ['ONT', 'Modem', 'Routeur', 'Décodeur TV', 'VoIP', 'Amplificateur', 'Splitter', 'Câble', 'Autre']
const newEquip = ref({ serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' })
const displayDuration = computed({
get: () => job.value?.duration_h || 1,
set: v => { if (job.value) job.value.duration_h = v },
})
const jobTypes = [
{ label: 'Installation', value: 'Installation' },
{ label: 'Réparation', value: 'Repair' },
{ label: 'Maintenance', value: 'Maintenance' },
{ label: 'Inspection', value: 'Inspection' },
{ label: 'Livraison', value: 'Delivery' },
{ label: 'Autre', value: 'Other' },
]
const priorities = [
{ label: 'Basse', value: 'low' },
{ label: 'Moyenne', value: 'medium' },
{ label: 'Haute', value: 'high' },
{ label: 'Urgente', value: 'urgent' },
]
const statusColor = computed(() => { const statusColor = computed(() => {
const map = { Scheduled: 'blue', assigned: 'blue', 'In Progress': 'orange', in_progress: 'orange', Completed: 'green' } const map = { Scheduled: 'blue', assigned: 'blue', 'In Progress': 'orange', in_progress: 'orange', Completed: 'green', Cancelled: 'grey' }
return map[job.value?.status] || 'grey' return map[job.value?.status] || 'grey'
}) })
const statusLabel = computed(() => { const statusLabel = computed(() => {
const map = { Scheduled: 'Planifie', assigned: 'Assigne', 'In Progress': 'En cours', in_progress: 'En cours', Completed: 'Termine' } const map = { Scheduled: 'Planifié', assigned: 'Assigné', 'In Progress': 'En cours', in_progress: 'En cours', Completed: 'Terminé', Cancelled: 'Annulé' }
return map[job.value?.status] || job.value?.status || '' return map[job.value?.status] || job.value?.status || ''
}) })
async function apiFetch (url) { const locationAddress = computed(() => {
const res = await fetch(BASE_URL + url) const loc = locationDetail.value
if (!res.ok) throw new Error('API ' + res.status) if (!loc) return job.value?.service_location_name || ''
return (await res.json()).data return [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ')
})
function eqIcon (type) {
const map = { ONT: 'settings_input_hdmi', Modem: 'router', Routeur: 'wifi', 'Décodeur TV': 'tv', VoIP: 'phone' }
return map[type] || 'memory'
}
function eqStatusColor (s) {
if (s === 'Actif') return 'green'
if (s === 'Défectueux' || s === 'Perdu') return 'red'
return 'grey'
} }
async function apiUpdate (doctype, name, data) { // --- Load job + related data ---
await fetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name), {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
})
}
async function loadJob () { async function loadJob () {
loading.value = true loading.value = true
try { try {
job.value = await apiFetch('/api/resource/Dispatch Job/' + encodeURIComponent(jobName.value)) job.value = await getDoc('Dispatch Job', jobName.value)
if (job.value.service_location) { if (job.value.service_location) {
const params = new URLSearchParams({ filters: JSON.stringify({ service_location: job.value.service_location }), fields: JSON.stringify(['name','serial_number','equipment_type','brand','model','status']), limit_page_length: 50 }) getDoc('Service Location', job.value.service_location)
try { equipment.value = await apiFetch('/api/resource/Service Equipment?' + params) || [] } catch {} .then(loc => { locationDetail.value = loc })
.catch(() => {})
} }
loadEquipment()
} catch (e) { } catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message }) Notify.create({ type: 'negative', message: 'Erreur chargement job: ' + e.message })
} finally { loading.value = false } } finally {
loading.value = false
}
} }
async function loadEquipment () {
if (!job.value?.service_location && !job.value?.customer) return
loadingEquip.value = true
try {
const filters = job.value.service_location
? { service_location: job.value.service_location }
: { customer: job.value.customer }
equipment.value = await listDocs('Service Equipment', {
filters,
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'mac_address', 'status', 'customer_name'],
limit: 50,
})
} catch { equipment.value = [] }
finally { loadingEquip.value = false }
}
// --- Save / status ---
async function saveField (field, value) { async function saveField (field, value) {
if (!job.value?.name) return if (!job.value?.name) return
try { await apiUpdate('Dispatch Job', job.value.name, { [field]: value }) } catch {} try { await updateDoc('Dispatch Job', job.value.name, { [field]: value }) }
catch (e) { Notify.create({ type: 'negative', message: 'Erreur sauvegarde: ' + e.message }) }
} }
async function updateStatus (status) { async function updateStatus (status) {
saving.value = true saving.value = true
try { try {
await apiUpdate('Dispatch Job', job.value.name, { status }) await updateDoc('Dispatch Job', job.value.name, { status })
job.value.status = status job.value.status = status
Notify.create({ type: 'positive', message: status === 'Completed' ? 'Termine' : 'En route !' }) const msgs = { 'In Progress': 'En route !', Completed: 'Job terminé', Scheduled: 'Job réouvert' }
Notify.create({ type: 'positive', message: msgs[status] || status, icon: status === 'Completed' ? 'check_circle' : 'directions_car' })
} catch (e) { } catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message }) Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally { saving.value = false } } finally { saving.value = false }
} }
// --- GPS & misc ---
function openGps () { function openGps () {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(job.value.service_location_name || '')}`, '_blank') const loc = locationDetail.value
if (loc?.latitude && loc?.longitude) {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${loc.latitude},${loc.longitude}`, '_blank')
return
}
const addr = loc
? [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ')
: (job.value?.service_location_name || '')
window.open(`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(addr)}`, '_blank')
} }
function openInErp () { window.open(`${BASE_URL}/app/dispatch-job/${job.value.name}`, '_blank') }
function openInErp () {
window.open(`${BASE_URL}/app/dispatch-job/${job.value.name}`, '_blank')
}
function goScan () { function goScan () {
router.push({ path: '/j/scan', query: { job: job.value.name, customer: job.value.customer, customer_name: job.value.customer_name, location: job.value.service_location, location_name: job.value.service_location_name } }) router.push({
name: 'tech-scan',
query: {
job: job.value.name,
customer: job.value.customer,
customer_name: job.value.customer_name,
location: job.value.service_location,
location_name: job.value.service_location_name,
},
})
}
// --- Equipment search / create / link ---
async function searchEquipment () {
const text = eqSearchText.value?.trim()
if (!text || text.length < 2) { eqSearchResults.value = []; return }
eqSearching.value = true
try {
let results = await listDocs('Service Equipment', {
filters: { serial_number: ['like', `%${text}%`] },
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'mac_address', 'customer_name', 'status'],
limit: 10,
})
if (!results.length) {
results = await listDocs('Service Equipment', {
filters: { mac_address: ['like', `%${text}%`] },
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'mac_address', 'customer_name', 'status'],
limit: 10,
})
}
eqSearchResults.value = results
} catch { eqSearchResults.value = [] }
finally { eqSearching.value = false }
}
async function linkEquipToJob (eq) {
try {
const updates = {}
if (job.value.customer) updates.customer = job.value.customer
if (job.value.service_location) updates.service_location = job.value.service_location
await updateDoc('Service Equipment', eq.name, updates)
equipment.value.push(eq)
searchEquipDialog.value = false
eqSearchText.value = ''
eqSearchResults.value = []
Notify.create({ type: 'positive', message: `${eq.equipment_type} lié au job`, icon: 'link' })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
}
}
async function createAndLinkEquip () {
if (!newEquip.value.serial_number?.trim()) {
Notify.create({ type: 'warning', message: 'Numéro de série requis' })
return
}
creatingEquip.value = true
try {
const data = {
...newEquip.value,
status: 'Actif',
customer: job.value.customer || '',
service_location: job.value.service_location || '',
}
const doc = await createDoc('Service Equipment', data)
equipment.value.push(doc)
Notify.create({ type: 'positive', message: 'Équipement créé et lié', icon: 'check_circle' })
createEquipDialog.value = false
newEquip.value = { serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' }
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
creatingEquip.value = false
}
} }
onMounted(loadJob) onMounted(loadJob)
@ -169,18 +430,29 @@ onMounted(loadJob)
<style lang="scss" scoped> <style lang="scss" scoped>
.job-detail-page { padding: 0 !important; display: flex; flex-direction: column; min-height: 100%; } .job-detail-page { padding: 0 !important; display: flex; flex-direction: column; min-height: 100%; }
.job-topbar { .job-topbar {
display: flex; align-items: center; padding: 8px 8px 8px 4px; display: flex; align-items: center; padding: 8px 8px 8px 4px;
background: linear-gradient(135deg, #3f3d7a 0%, #5c59a8 100%); color: white; background: linear-gradient(135deg, #3f3d7a 0%, #5c59a8 100%); color: white;
position: sticky; top: 0; z-index: 10; position: sticky; top: 0; z-index: 10;
} }
.job-status-hero { .job-status-hero {
text-align: center; padding: 20px 16px; background: #f5f7fa; text-align: center; padding: 20px 16px; background: #f5f7fa;
border-bottom: 1px solid rgba(0,0,0,0.06); border-bottom: 1px solid rgba(0,0,0,0.06);
&.status-in-progress, &.status-in_progress { background: #fff8e1; } &.status-in-progress, &.status-in_progress { background: #fff8e1; }
&.status-completed { background: #e8f5e9; } &.status-completed { background: #e8f5e9; }
&.status-cancelled { background: #fafafa; }
} }
.action-btn { min-width: 140px; font-weight: 600; border-radius: 12px; } .action-btn { min-width: 140px; font-weight: 600; border-radius: 12px; }
.job-content { flex: 1; overflow-y: auto; padding-bottom: 80px !important; }
.job-content {
flex: 1;
overflow-y: auto;
padding-bottom: 80px !important; // clear TechLayout bottom tab bar
}
.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.85em; }
.text-overline { font-size: 0.68rem; letter-spacing: 0.08em; } .text-overline { font-size: 0.68rem; letter-spacing: 0.08em; }
</style> </style>

View File

@ -1,19 +1,19 @@
<!-- <!--
TechScanPage camera-based equipment scanner for field techs at /j/scan. TechScanPage camera-based equipment scanner for field techs at /j/scan.
Three capture modes (useScanner docs in src/composables/useScanner.js): What it does in one sentence: the tech points their phone at an ONT/router
Caméra (live) default, html5-qrcode continuous stream, instant + offline label, Gemini reads the serial, and we look it up in ERPNext, optionally
Photo native camera, local multi-strip + Gemini fallback, offline-queued auto-linking the equipment to the tech's current Dispatch Job.
Manuel plain text input, for bent/unreadable stickers
ERPNext relationships touched (see docs/features/vision-ocr.md §10): ERPNext relationships touched (see docs/VISION_AND_OCR.md §10 for the full
data-model diagram):
Dispatch Job Customer Service Location Service Equipment Dispatch Job Customer Service Location Service Equipment
(serial_number, barcode, (serial_number, barcode,
mac_address, status) mac_address, status)
The tech arrives via `/j/scan?job=JOB-001&customer=CUST-123&location=LOC-456` The tech arrives via `/j/scan?job=JOB-001&customer=CUST-123&location=LOC-456`
when a scanned serial matches an unlinked Service Equipment row, we when the scanned serial matches an unlinked Service Equipment row, we
auto-patch customer + service_location on that row so the device is auto-patch customer + service_location on that row so the device is
provably tied to that install address before the tech leaves. provably tied to that install address before the tech leaves.
@ -21,11 +21,11 @@
same Customer + Service Location, so the scan result flows through same Customer + Service Location, so the scan result flows through
naturally for any downstream ticket view. naturally for any downstream ticket view.
History: this page started as apps/field/src/pages/ScanPage.vue (live + multi- Ported from apps/field/src/pages/ScanPage.vue during the fieldops
strip), was briefly reduced to Gemini-only (Apr 2026), and is now hybrid. unification (see docs/ARCHITECTURE.md §"Legacy Retirement Plan"). Adapted
Live camera is the default because pointing-and-scanning is 10× faster than for the ops router:
take-photo-wait-for-AI on a normal QR code. Photo+Gemini is kept as a - device-detail route name: 'tech-device' (was 'device' in field)
second chance for damaged or text-only labels. - same query-param contract from TechJobDetailPage.goScan()
--> -->
<template> <template>
<q-page padding class="scan-page"> <q-page padding class="scan-page">
@ -46,99 +46,55 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
<!-- Mode tabs: Caméra / Photo / Manuel --> <!-- Camera capture button -->
<q-tabs v-model="mode" dense no-caps active-color="primary" indicator-color="primary" align="justify" <div class="text-center">
class="q-mb-md" @update:model-value="onModeChange">
<q-tab name="live" icon="videocam" label="Caméra" />
<q-tab name="photo" icon="photo_camera" label="Photo" />
<q-tab name="manual" icon="keyboard" label="Manuel" />
</q-tabs>
<!-- ==== Live camera mode (default, fastest) ==== -->
<div v-show="mode === 'live'" class="live-wrap">
<div id="live-reader" class="live-reader" />
<div class="text-center q-mt-sm">
<q-btn v-if="!scanner.live.value" color="primary" icon="play_arrow"
label="Démarrer la caméra" @click="startLive" unelevated />
<q-btn v-else color="negative" icon="stop" label="Arrêter" @click="scanner.stopLive()" outline />
</div>
<div v-if="scanner.live.value" class="text-caption text-center text-grey q-mt-xs">
Cadrez un code-barres détection automatique
</div>
</div>
<!-- ==== Photo mode (native camera + local scan + Gemini fallback) ==== -->
<div v-show="mode === 'photo'" class="text-center">
<q-btn <q-btn
color="primary" icon="photo_camera" label="Prendre une photo" color="primary" icon="photo_camera" label="Scanner"
size="lg" rounded unelevated size="lg" rounded unelevated
@click="takePhoto" @click="takePhoto"
:loading="scanner.scanning.value" :loading="scanner.scanning.value"
class="q-px-xl" class="q-px-xl"
/> />
<input ref="cameraInput" type="file" accept="image/*" capture="environment" class="hidden" @change="onPhoto" /> <input ref="cameraInput" type="file" accept="image/*" capture="environment" class="hidden" @change="onPhoto" />
<div class="text-caption text-grey q-mt-sm"> </div>
Jusqu'à {{ scanner.MAX_BARCODES }} codes par photo · IA activée si la lecture locale échoue
</div>
<!-- Pending scan indicator (signal faible queue offline) --> <!-- Pending scan indicator (signal faible queue offline) -->
<div v-if="offline.pendingVisionCount > 0" class="text-center q-mt-sm"> <div v-if="offline.pendingVisionCount > 0" class="text-center q-mt-sm">
<q-chip icon="wifi_tethering_off" color="orange" text-color="white" dense clickable <q-chip icon="wifi_tethering_off" color="orange" text-color="white" dense clickable @click="offline.syncVisionQueue()">
@click="offline.syncVisionQueue()"> {{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }} en attente · toucher pour réessayer
{{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }} </q-chip>
en attente · toucher pour réessayer </div>
</q-chip>
</div>
<!-- Last captured photo (thumbnail) --> <!-- Last captured photo (thumbnail) -->
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md"> <div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" /> <img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
<div v-if="scanner.scanning.value" class="preview-overlay"> <div v-if="scanner.scanning.value" class="preview-overlay">
<q-spinner-dots size="32px" color="white" /> <q-spinner-dots size="32px" color="white" />
<div class="text-white text-caption q-mt-xs">Analyse...</div> <div class="text-white text-caption q-mt-xs">Analyse Gemini...</div>
</div>
</div>
<!-- Photo history (small thumbnails) -->
<div v-if="scanner.photos.value.length > 1" class="q-mt-md">
<div class="text-caption text-grey q-mb-xs">Photos capturées</div>
<div class="row q-gutter-xs justify-center">
<div v-for="(p, i) in scanner.photos.value" :key="i" class="photo-thumb" @click="viewPhoto(p)">
<img :src="p.url" />
<q-badge v-if="p.codes.length" color="green" floating :label="p.codes.length" />
</div>
</div>
</div> </div>
</div> </div>
<!-- ==== Manual entry ==== --> <!-- Error / status -->
<div v-show="mode === 'manual'">
<q-input v-model="manualCode" label="Saisie manuelle SN / MAC / code-barres"
outlined dense autofocus @keyup.enter="addManual">
<template v-slot:prepend><q-icon name="keyboard" /></template>
<template v-slot:append>
<q-btn flat dense icon="add" :disable="!manualCode.trim()" @click="addManual" />
</template>
</q-input>
</div>
<!-- ==== Error / status (all modes) ==== -->
<div v-if="scanner.error.value" class="text-caption text-center q-mt-sm text-negative"> <div v-if="scanner.error.value" class="text-caption text-center q-mt-sm text-negative">
{{ scanner.error.value }} {{ scanner.error.value }}
</div> </div>
<!-- ==== Scanned barcodes (shared across all modes) ==== --> <!-- Manual entry fallback -->
<q-input v-model="manualCode" label="Saisie manuelle SN / MAC" outlined dense class="q-mt-md"
@keyup.enter="addManual">
<template v-slot:append>
<q-btn flat dense icon="add" @click="addManual" :disable="!manualCode.trim()" />
</template>
</q-input>
<!-- Scanned barcodes max 5 (see useScanner.MAX_BARCODES) -->
<div v-if="scanner.barcodes.value.length > 0" class="q-mt-md"> <div v-if="scanner.barcodes.value.length > 0" class="q-mt-md">
<div class="row items-center q-mb-xs"> <div class="text-subtitle2 q-mb-xs">Codes détectés ({{ scanner.barcodes.value.length }})</div>
<div class="text-subtitle2 col">Codes détectés ({{ scanner.barcodes.value.length }}/{{ scanner.MAX_BARCODES }})</div>
<q-btn flat dense size="sm" icon="clear_all" label="Effacer" @click="scanner.clearBarcodes()" />
</div>
<q-card v-for="bc in scanner.barcodes.value" :key="bc.value" class="q-mb-sm"> <q-card v-for="bc in scanner.barcodes.value" :key="bc.value" class="q-mb-sm">
<q-card-section class="q-py-sm row items-center no-wrap"> <q-card-section class="q-py-sm row items-center no-wrap">
<q-icon name="qr_code" class="q-mr-sm" color="primary" /> <q-icon name="qr_code" class="q-mr-sm" color="primary" />
<div class="col"> <div class="col">
<div class="text-subtitle2 mono">{{ bc.value }}</div> <div class="text-subtitle2 mono">{{ bc.value }}</div>
<div class="text-caption text-grey">{{ bc.region }}</div>
</div> </div>
<q-btn flat dense icon="search" @click="lookupDevice(bc.value)" :loading="lookingUp === bc.value" /> <q-btn flat dense icon="search" @click="lookupDevice(bc.value)" :loading="lookingUp === bc.value" />
<q-btn flat dense icon="close" color="negative" @click="scanner.removeBarcode(bc.value)" /> <q-btn flat dense icon="close" color="negative" @click="scanner.removeBarcode(bc.value)" />
@ -175,6 +131,17 @@
</q-card> </q-card>
</div> </div>
<!-- Photo history (small thumbnails) -->
<div v-if="scanner.photos.value.length > 1" class="q-mt-md">
<div class="text-caption text-grey q-mb-xs">Photos capturées</div>
<div class="row q-gutter-xs">
<div v-for="(p, i) in scanner.photos.value" :key="i" class="photo-thumb" @click="viewPhoto(p)">
<img :src="p.url" />
<q-badge v-if="p.codes.length" color="green" floating :label="p.codes.length" />
</div>
</div>
</div>
<!-- Link all to account (manual, when no job context) --> <!-- Link all to account (manual, when no job context) -->
<div v-if="scanner.barcodes.value.length > 0 && !jobContext && hasUnlinked" class="q-mt-sm"> <div v-if="scanner.barcodes.value.length > 0 && !jobContext && hasUnlinked" class="q-mt-sm">
<q-btn color="orange" icon="link" label="Lier les équipements à un service" <q-btn color="orange" icon="link" label="Lier les équipements à un service"
@ -264,7 +231,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, nextTick, onBeforeUnmount } from 'vue' import { ref, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useScanner } from 'src/composables/useScanner' import { useScanner } from 'src/composables/useScanner'
import { listDocs, createDoc, updateDoc } from 'src/api/erp' import { listDocs, createDoc, updateDoc } from 'src/api/erp'
@ -274,8 +241,8 @@ import { Notify } from 'quasar'
const route = useRoute() const route = useRoute()
const offline = useOfflineStore() const offline = useOfflineStore()
// Each new code triggers both a toast AND a silent ERPNext lookup. The // Each new code triggers both a toast AND a silent ERPNext lookup. The
// callback fires for live scans, photo scans, queued retries, and manual // callback fires for synchronous scans AND for scans that complete later
// entries the tech gets notified either way. // from the offline vision queue the tech gets notified either way.
const scanner = useScanner({ const scanner = useScanner({
onNewCode: (code) => { onNewCode: (code) => {
Notify.create({ type: 'positive', message: code, timeout: 2500, icon: 'qr_code', color: 'deep-purple' }) Notify.create({ type: 'positive', message: code, timeout: 2500, icon: 'qr_code', color: 'deep-purple' })
@ -283,8 +250,6 @@ const scanner = useScanner({
}, },
}) })
// Default to live camera that's what techs use 90% of the time.
const mode = ref('live')
const cameraInput = ref(null) const cameraInput = ref(null)
const manualCode = ref('') const manualCode = ref('')
const lookingUp = ref(null) const lookingUp = ref(null)
@ -330,28 +295,7 @@ const hasUnlinked = computed(() =>
}) })
) )
// Mode switch // Camera capture
function onModeChange (newMode) {
// Leaving Live cut the camera so we're not streaming in the background.
if (newMode !== 'live' && scanner.live.value) scanner.stopLive()
}
// Defensive: make sure we don't leak the camera if the page unmounts while live.
onBeforeUnmount(() => {
if (scanner.live.value) scanner.stopLive()
})
// Live camera
async function startLive () {
// #live-reader is inside v-show it's always in the DOM from first render.
// nextTick is defensive in case a parent transition hid it momentarily.
await nextTick()
await scanner.startLive('live-reader')
}
// Photo camera
function takePhoto () { function takePhoto () {
// Reset so the same file re-triggers change when tech scans, undoes, scans // Reset so the same file re-triggers change when tech scans, undoes, scans
@ -377,16 +321,11 @@ function viewPhoto (photo) {
function addManual () { function addManual () {
const code = manualCode.value.trim() const code = manualCode.value.trim()
if (!code) return if (!code) return
if (scanner.barcodes.value.length >= scanner.MAX_BARCODES) { if (!scanner.barcodes.value.find(b => b.value === code)) {
Notify.create({ type: 'warning', message: `Maximum ${scanner.MAX_BARCODES} codes` }) scanner.barcodes.value.push({ value: code, region: 'manuel' })
return lookupDevice(code)
}
if (scanner.addCode(code, 'manuel')) {
manualCode.value = ''
} else {
Notify.create({ type: 'info', message: 'Code déjà ajouté' })
manualCode.value = ''
} }
manualCode.value = ''
} }
// Device lookup: 3-tier fallback // Device lookup: 3-tier fallback
@ -598,25 +537,6 @@ async function linkDeviceToService () {
padding-bottom: 80px !important; /* TechLayout has a bottom tab bar */ padding-bottom: 80px !important; /* TechLayout has a bottom tab bar */
} }
.live-wrap {
max-width: 480px;
margin: 0 auto;
}
.live-reader {
width: 100%;
min-height: 280px;
border-radius: 12px;
overflow: hidden;
background: #000;
// html5-qrcode injects a <video> make it fill the container
:deep(video) {
width: 100% !important;
height: auto !important;
display: block;
}
}
.photo-preview { .photo-preview {
position: relative; position: relative;
text-align: center; text-align: center;
@ -655,6 +575,6 @@ async function linkDeviceToService () {
} }
.mono { .mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: monospace;
} }
</style> </style>

View File

@ -1,18 +1,26 @@
# Gigafibre FSM — Documentation # Gigafibre FSM — Documentation
> **Start here.** Everything you need to understand the system is one or two > **Start here.** Pick a row by intent, jump to the module index when you
> clicks away. Pick the row that matches why you're here. > need scope, or go straight to the cross-module map when you're chasing a
> call that hops surfaces.
**Last refreshed:** 2026-04-22 (expanded module index, added dispatch /
customer portal / tech mobile docs, cross-interaction matrix)
--- ---
## Quick nav by intent ## 1. Quick nav by intent
| I want to… | Open | | I want to… | Open |
|---|---| |---|---|
| **See the plan** — what's shipped, what's queued, with clickable links to every live module | [roadmap.md](roadmap.md) | | **See the plan** — what's shipped, what's queued, with clickable links to every live module | [roadmap.md](roadmap.md) |
| **Understand the system end-to-end** — services, containers, networks, SSO, Traefik routes | [architecture/overview.md](architecture/overview.md) | | **Understand the system end-to-end** — services, containers, networks, SSO, Traefik routes | [architecture/overview.md](architecture/overview.md) |
| **See who calls who** — one-page matrix of every module → module interaction | [architecture/module-interactions.md](architecture/module-interactions.md) |
| **Build a new feature** — UI patterns, folder layout, router conventions, Pinia, feature-sliced design | [architecture/app-design.md](architecture/app-design.md) | | **Build a new feature** — UI patterns, folder layout, router conventions, Pinia, feature-sliced design | [architecture/app-design.md](architecture/app-design.md) |
| **Touch ERPNext data** — doctypes, customer → subscription → equipment → invoice, "Lead to Live" flow | [architecture/data-model.md](architecture/data-model.md) | | **Touch ERPNext data** — doctypes, customer → subscription → equipment → invoice, "Lead to Live" flow | [architecture/data-model.md](architecture/data-model.md) |
| **Work on dispatch** — drag-and-drop scheduling, tech assignment, travel-time optimization, magic-link SMS | [features/dispatch.md](features/dispatch.md) |
| **Work on the tech mobile app** — scan-to-identify, equipment install, job status, JWT auth | [features/tech-mobile.md](features/tech-mobile.md) |
| **Work on the customer portal** — passwordless magic-link, invoice + ticket self-service | [features/customer-portal.md](features/customer-portal.md) |
| **Work on billing, Stripe, payments** — subscription lifecycle, invoice OCR, payment reconciliation, PPA | [features/billing-payments.md](features/billing-payments.md) | | **Work on billing, Stripe, payments** — subscription lifecycle, invoice OCR, payment reconciliation, PPA | [features/billing-payments.md](features/billing-payments.md) |
| **Touch CPE / modems / ONTs** — GenieACS, Oktopus, TR-069 → TR-369 migration, TP-Link XX230v diagnostics | [features/cpe-management.md](features/cpe-management.md) | | **Touch CPE / modems / ONTs** — GenieACS, Oktopus, TR-069 → TR-369 migration, TP-Link XX230v diagnostics | [features/cpe-management.md](features/cpe-management.md) |
| **Build or debug the scanner / OCR** — Gemini vision pipeline, barcode/equipment/invoice endpoints, offline queue, AI_API_KEY rotation | [features/vision-ocr.md](features/vision-ocr.md) | | **Build or debug the scanner / OCR** — Gemini vision pipeline, barcode/equipment/invoice endpoints, offline queue, AI_API_KEY rotation | [features/vision-ocr.md](features/vision-ocr.md) |
@ -23,25 +31,194 @@
--- ---
## Folder map ## 2. Module index
Every deployed surface, by app. Each row links to its primary doc and
names the exact code paths so you can `grep` from here.
### 2a. Ops SPA (`apps/ops/`) — `https://erp.gigafibre.ca/ops/`
**Single pane of glass for internal teams.** Quasar PWA (SPA mode in dev,
PWA mode in prod), Vue 3 + Pinia, hash router. Wrapped by Authentik via
Traefik `forwardAuth` on `/ops/*`. Bottom of the same app hosts the tech
mobile module on `/j/*` with magic-link auth.
| Module | URL | Purpose | Primary doc | Primary code |
|---|---|---|---|---|
| **Dashboard** | `/ops/#/` | KPIs (MRR, AR, active tickets), dispatch summary, live SSE feed | — | `src/pages/DashboardPage.vue` |
| **Clients** | `/ops/#/clients` · `/clients/:id` | Customer 360: profile, subscriptions, invoices, equipment, tickets, communication timeline | — | `src/pages/ClientsPage.vue` · `ClientDetailPage.vue` · `src/modules/clients/` |
| **Dispatch** | `/ops/#/dispatch` | Drag-and-drop tech schedule, job board, travel-time optimization, SMS magic-link issuance | [features/dispatch.md](features/dispatch.md) | `src/pages/DispatchPage.vue` · `src/modules/dispatch/` |
| **Tickets** | `/ops/#/tickets` | Support ticket board, SLA clock, linked equipment + jobs | — | `src/pages/TicketsPage.vue` · `src/modules/tickets/` |
| **Équipe** | `/ops/#/equipe` | Technician roster, skills/tags, availability, today's jobs | [features/dispatch.md §6](features/dispatch.md) | `src/pages/EquipePage.vue` · `src/modules/equipe/` |
| **Rapports** | `/ops/#/rapports` + `/revenus` · `/ventes` · `/taxes` · `/ar` | Revenue, sales, tax, AR reports (renders in-browser; PDF export via hub) | [features/billing-payments.md §9](features/billing-payments.md) | `src/pages/Rapports*.vue` · `src/modules/rapports/` |
| **OCR** | `/ops/#/ocr` | Upload scanned supplier bills → Gemini parse → pre-fill Purchase Invoice | [features/vision-ocr.md §4](features/vision-ocr.md) | `src/pages/OcrPage.vue` |
| **Settings** | `/ops/#/settings` | Feature flags, integration keys, user management (mirror of ERPNext) | — | `src/pages/SettingsPage.vue` |
| **Telephony** | `/ops/#/telephony` | SIP click-to-dial (Twilio Voice SDK + sip.js fallback), call history | [architecture/overview.md §3](architecture/overview.md) | `src/pages/TelephonyPage.vue` |
| **Agent Flows** | `/ops/#/agent-flows` | Visual editor for Flow Template (automation runtime) | [features/flow-editor.md](features/flow-editor.md) | `src/pages/AgentFlowsPage.vue` |
| **Network** | `/ops/#/network` | Cytoscape-rendered OLT/ONT topology, live link status from GenieACS | [features/cpe-management.md](features/cpe-management.md) | `src/pages/NetworkPage.vue` |
### 2b. Ops tech module (`apps/ops/src/modules/tech/`) — `https://erp.gigafibre.ca/ops/#/j/*`
**Same SPA, different mount.** Techs hit a magic-link SMS, land on `/j/:token`,
the token redirects to `/j` with JWT in localStorage. Mobile-first layout
with bottom tab bar.
| Route | Purpose | Primary doc | Primary code |
|---|---|---|---|
| `/j` | Today's jobs, grouped by status | [features/tech-mobile.md §4a](features/tech-mobile.md) | `TechTasksPage.vue` |
| `/j/job/:name` | Job detail + equipment management (Ajouter/Scanner/Rechercher/Créer) | [features/tech-mobile.md §5](features/tech-mobile.md) | `TechJobDetailPage.vue` |
| `/j/scan` | Standalone scanner (native camera → Gemini) | [features/tech-mobile.md §3](features/tech-mobile.md) | `TechScanPage.vue` |
| `/j/device/:serial` | Per-device detail, diagnostic shortcut | — | `TechDevicePage.vue` |
| `/j/diagnostic` | In-browser speed test + GenieACS probe | [features/cpe-management.md](features/cpe-management.md) | `TechDiagnosticPage.vue` |
| `/j/more` | Settings, token expiry, logout | — | `TechMorePage.vue` |
| `/j/:token` | Magic-link handler (last route, accepts any JWT) | [features/tech-mobile.md §2](features/tech-mobile.md) | `TechTasksPage.vue` (same component, different entry) |
### 2c. Field PWA (`apps/field/`) — `https://erp.gigafibre.ca/field/`
**Transitional standalone PWA, retiring.** Authentik-protected; byte-for-byte
closer to the legacy workflow techs had before the Ops tech module
existed. No magic-link — tech logs in via Authentik. Kept alive while the
SMS link still points at the hub SSR page (see tech-mobile doc §1).
Same page set as §2b (TasksPage, ScanPage, JobDetailPage, DevicePage,
DiagnosticPage, MorePage), same Gemini-native scanner composable.
`apps/field/src/pages/JobDetailPage.vue` is the reference implementation
we port *from* when the Ops tech module drifts.
### 2d. Customer portal (`apps/client/`) — `https://portal.gigafibre.ca/`
**Passwordless self-service.** Standalone Quasar PWA served by a dedicated
nginx container (`client-portal`) bind-mounted from `/opt/client-app/` on
the Proxmox VM. No Authentik — customers get a magic-link email (HS256
JWT, 24h TTL).
| Module | URL | Purpose | Primary doc | Primary code |
|---|---|---|---|---|
| **Login** | `/#/login` | Email entry → magic-link email send | [features/customer-portal.md §3](features/customer-portal.md) | `src/pages/LoginPage.vue` |
| **Dashboard** | `/#/` | Balance, next invoice, active tickets, recent activity | [features/customer-portal.md §4a](features/customer-portal.md) | `DashboardPage.vue` |
| **Account** | `/#/account` | Profile, saved payment methods (Stripe PM), address book | [features/billing-payments.md §6](features/billing-payments.md) | `AccountPage.vue` |
| **Catalog / Cart** | `/#/catalog` · `/#/cart` | Shop add-ons (extra mesh, static IP), add to Stripe Checkout | [features/billing-payments.md](features/billing-payments.md) | `CatalogPage.vue` · `CartPage.vue` |
| **Invoices** | `/#/invoices` · `/#/invoices/:name` | List + PDF of all Sales Invoices, Stripe-linked pay button | [features/billing-payments.md §8](features/billing-payments.md) | `InvoicesPage.vue` · `InvoiceDetailPage.vue` |
| **Tickets** | `/#/tickets` · `/#/tickets/:name` | Create support ticket, reply thread, attach photos | — | `TicketsPage.vue` · `TicketDetailPage.vue` |
| **Messages** | `/#/messages` | Unified comm thread (SMS + email) with support | — | `MessagesPage.vue` |
| **Payment flows** | `/#/payment-success` · `/payment-cancel` · `/payment-card-added` · `/order-success` | Stripe return URL landings | [features/billing-payments.md §7](features/billing-payments.md) | `Payment*.vue` · `OrderSuccessPage.vue` |
### 2e. Legacy portal (`apps/portal/`) — **retired 2026-04-22**
Old ERPNext-embedded customer portal. Host `client.gigafibre.ca` now 307s
to `portal.gigafibre.ca/*` via Traefik; see
`apps/portal/traefik-client-portal.yml`. Codebase retained as historical
reference only. Do not build.
### 2f. Marketing site (`apps/website/`) — `https://www.gigafibre.ca/`
React + Vite marketing site. Self-contained, no ERPNext dependency,
no docs beyond its own README.
---
## 3. Services (backends)
Everything in `services/*` is backend infrastructure. Usually you don't
edit these unless you're adding a new endpoint.
| Service | Port | Role | Primary docs |
|---|---|---|---|
| `services/targo-hub/` | `msg.gigafibre.ca:3300` | **API gateway.** Vision (Gemini), magic-link JWT, Twilio SMS, SSE fanout, Stripe webhooks, ACS proxy, Legacy-DB read proxy, tech-mobile SSR | [overview.md §3](architecture/overview.md) · [vision-ocr.md](features/vision-ocr.md) · [customer-portal.md](features/customer-portal.md) · [tech-mobile.md](features/tech-mobile.md) · [billing-payments.md](features/billing-payments.md) |
| `services/modem-bridge/` | `:3301` (internal) | Playwright + Chromium wrapper that logs into TP-Link XX230v modems and reads encrypted TR-181 data | [cpe-management.md](features/cpe-management.md) |
| `services/docuseal/` | `docs.gigafibre.ca:3000` | Contract signing for commercial quotes + residential service agreements | [flow-editor.md §5](features/flow-editor.md) (briefly) |
| `services/legacy-db/` | `:3305` (internal) | Read-only proxy over the retired MariaDB for lookups that haven't migrated | [architecture/overview.md](architecture/overview.md) |
**ERPNext** (`erp.gigafibre.ca`) is not under `services/` — it's a standard
Frappe-Bench install on the VM. See
[architecture/data-model.md](architecture/data-model.md) for the doctype
catalog.
---
## 4. Cross-module interactions (one-page map)
> Full matrix + sequence diagrams in
> [architecture/module-interactions.md](architecture/module-interactions.md).
> The summary below is just the high-traffic edges.
```text
┌───────────────────────────────────────┐
│ ERPNext (source of truth) │
│ Customer · Subscription · │
│ Equipment · Dispatch Job · │
│ Sales Invoice · Ticket │
└──┬──────────────┬──────────┬──────────┘
│ │ │
REST+token │ REST+token │ REST+token
│ │ │
┌────────────▼──┐ ┌───────▼────────┐ │
│ targo-hub │ │ Ops SPA │ │
│ (gateway) │◄──┤ apps/ops/ │ │
│ │ └────┬───────────┘ │
│ /vision/* │ │ │
│ /magic-link/* │ │ bundles │
│ /sse │ │ tech module │
│ /sms │ │ at /j/* │
│ /stripe/* │ │ │
│ /acs/* │ └─────────────┘
│ /t/{token} │ │
└──┬──────┬─────┘ │
│ │ │
SMS │ │ email (magic link) │
/SSE │ │ │
▼ ▼ ▼
┌─────────────────┐ ┌────────────────────────┐
│ Tech mobile │ │ Customer portal │
│ (SSR OR Vue) │ │ apps/client/ │
│ /t or /ops/#/j/ │ │ portal.gigafibre.ca │
└─────────────────┘ └────────────────────────┘
```
**Highest-traffic edges (read these docs first if you're chasing one):**
| From | To | Via | Where it's documented |
|---|---|---|---|
| Dispatch (Ops) | Tech mobile | Magic-link SMS → hub/magic-link.js → Twilio | [dispatch.md §5](features/dispatch.md) + [tech-mobile.md §2](features/tech-mobile.md) |
| Tech mobile scanner | Gemini | POST hub `/vision/barcodes` + `/vision/equipment` | [vision-ocr.md §2](features/vision-ocr.md) + [tech-mobile.md §3](features/tech-mobile.md) |
| Ops OCR page | Gemini | POST hub `/vision/invoice` | [vision-ocr.md §3](features/vision-ocr.md) |
| Customer portal | ERPNext | Authenticated via customer JWT → hub proxies `/api/resource/*` | [customer-portal.md §6](features/customer-portal.md) |
| Stripe | ERPNext | Webhook → hub `/stripe/webhook` → creates Payment Entry | [billing-payments.md §7](features/billing-payments.md) |
| Ops dispatch | SSE live updates | hub `/sse` (tech status, job flip, ETA) | [dispatch.md §4](features/dispatch.md) |
| Agent-flows | Any module | Runtime in hub → triggers any ERPNext op via service token | [flow-editor.md §6](features/flow-editor.md) |
| GenieACS | Modem-bridge | hub `/acs/probe` → modem-bridge for TP-Link deep params | [cpe-management.md §4](features/cpe-management.md) |
---
## 5. Folder map
```text ```text
docs/ docs/
├── README.md ← you are here ├── README.md ← you are here
├── roadmap.md ← phase tracker + live module URLs ├── roadmap.md ← phase tracker + live module URLs
├── architecture/ ← the system itself ├── architecture/ ← the system itself
│ ├── overview.md (services, Docker, Traefik, SSO, retirement plan) │ ├── overview.md (services, Docker, Traefik, SSO, retirement plan)
│ ├── data-model.md (ERPNext doctypes + "Lead to Live") │ ├── data-model.md (ERPNext doctypes + "Lead to Live")
│ └── app-design.md (Vue/Quasar patterns, feature-sliced layout) │ ├── app-design.md (Vue/Quasar patterns, feature-sliced layout)
│ └── module-interactions.md (full call graph + sequence diagrams)
├── features/ ← one doc per business capability ├── features/ ← one doc per business capability
│ ├── README.md (per-doc index)
│ ├── dispatch.md (Ops dispatch board, scheduling, tags, SMS)
│ ├── tech-mobile.md (Gemini-native scanner, equipment UX, JWT)
│ ├── customer-portal.md (Plan A magic-link, Stripe self-service)
│ ├── billing-payments.md (Stripe, invoices, subscriptions, PPA) │ ├── billing-payments.md (Stripe, invoices, subscriptions, PPA)
│ ├── cpe-management.md (GenieACS, Oktopus, XX230v) │ ├── cpe-management.md (GenieACS, Oktopus, XX230v, modem-bridge)
│ ├── vision-ocr.md (Gemini, scan pipeline, offline queue, keys) │ ├── vision-ocr.md (Gemini, scan pipeline, offline queue, keys)
│ └── flow-editor.md (Flow Template, triggers, runtime) │ └── flow-editor.md (Flow Template, triggers, runtime)
├── reference/ ← lookup material ├── reference/ ← lookup material
│ ├── erpnext-item-diff.md (new SKUs vs legacy gestionclient) │ ├── erpnext-item-diff.md (new SKUs vs legacy gestionclient)
│ └── legacy-wizard/ (read-only snapshot of the PHP wizard) │ └── legacy-wizard/ (read-only snapshot of the PHP wizard)
├── assets/ ← PPTX decks, screenshots, generated diagrams ├── assets/ ← PPTX decks, screenshots, generated diagrams
└── archive/ ← frozen docs, do not edit └── archive/ ← frozen docs, do not edit
├── HANDOFF-2026-04-18.md ├── HANDOFF-2026-04-18.md
├── MIGRATION.md ├── MIGRATION.md
@ -51,26 +228,52 @@ docs/
--- ---
## Conventions ## 6. Conventions
- **Edit in place.** Don't copy-paste a section into a new file — link to it instead. - **Edit in place.** Don't copy-paste a section into a new file — link to it instead.
- **Dated snapshots go to `archive/status-snapshots/`.** Don't create `STATUS_YYYY-MM-DD.md` at the root anymore. - **Module index = source of truth for URLs.** If you change a route or a
- **Code comments that reference a doc** use the full repo-relative path, e.g. deployed host, update §2 of this file *and* the relevant feature doc.
`// See docs/features/vision-ocr.md §10` — so `grep docs/` still surfaces them if we reorganize again. `grep -r "erp.gigafibre.ca" docs/` should not return stale hostnames.
- **Cross-doc links use repo-relative paths,** e.g.
`[features/dispatch.md](features/dispatch.md)` — so `git mv`ing a doc
surfaces every incoming link.
- **Code comments that reference a doc** use the full repo-relative path,
e.g. `// See docs/features/vision-ocr.md §10` — so `grep docs/` still
surfaces them if we reorganize again.
- **Dated snapshots go to `archive/status-snapshots/`.** Don't create
`STATUS_YYYY-MM-DD.md` at the root anymore.
- **The root `README.md`** (one level up) is the repo introduction for - **The root `README.md`** (one level up) is the repo introduction for
non-technical readers; `docs/README.md` (this file) is the engineering index. non-technical readers; `docs/README.md` (this file) is the engineering
index.
--- ---
## What changed on 2026-04-22 ## 7. What changed on 2026-04-22
The docs were flattened out of a single folder into `architecture/`, Big reorganization:
`features/`, `reference/`, `archive/`. Every file move used `git mv` so
`git log --follow` still works. The old paths
(`docs/ARCHITECTURE.md`, `docs/VISION_AND_OCR.md`, etc.) no longer exist — if
you land on one of those links from an external bookmark, use the table above
to find the new location.
The only place old names still appear is inside 1. **New feature docs** added: `dispatch.md`, `customer-portal.md`,
`archive/HANDOFF-2026-04-18.md` and `archive/status-snapshots/*.md`, where the `tech-mobile.md`. The first was missing entirely despite being the
links are intentionally preserved as a frozen historical record. most-used surface in Ops; the second codifies the Plan A magic-link
cutover; the third replaces a stale `tech-mobile.md` that incorrectly
referenced `html5-qrcode` (never shipped — scanner has always been
native camera + Gemini).
2. **New architecture doc** added: `module-interactions.md` — the
one-page call graph with sequence diagrams for the hot paths (dispatch
→ SMS → tech scan → equipment install).
3. **Module index** (§2 above) now lists *every* deployed surface, not
just the three that had feature docs. Dispatch, Tickets, Équipe,
Rapports, Telephony, Network, Agent Flows, OCR, and every customer-portal
page now have a named row.
4. **Customer portal topology clarified.** `portal.gigafibre.ca` is the
live SPA; `client.gigafibre.ca` 307s to portal. The legacy
`apps/portal/` tree is retained as historical reference only.
5. **Earlier flattening (still in effect):** docs were flattened out of a
single folder into `architecture/`, `features/`, `reference/`,
`archive/`. Every file move used `git mv` so `git log --follow` still
works. The old paths (`docs/ARCHITECTURE.md`, `docs/VISION_AND_OCR.md`,
etc.) no longer exist — if you land on one of those links from an
external bookmark, use §1 above to find the new location. Legacy
references remain only inside `archive/HANDOFF-2026-04-18.md` and
`archive/status-snapshots/*.md`, where they're preserved as a frozen
historical record.

View File

@ -1,11 +1,12 @@
# Architecture # Architecture
How the pieces fit together. Read [overview.md](overview.md) first unless you How the pieces fit together. Read [overview.md](overview.md) first unless
have a specific reason not to. you have a specific reason not to.
| Doc | Read when… | | Doc | Read when… |
|---|---| |---|---|
| [overview.md](overview.md) | You need the full service map — ERPNext, Ops PWA, targo-hub, DocuSeal, Authentik, Traefik, Docker networks, the legacy retirement plan | | [overview.md](overview.md) | You need the full service map — ERPNext, Ops PWA, targo-hub, DocuSeal, Authentik, Traefik, Docker networks, the legacy retirement plan |
| [module-interactions.md](module-interactions.md) | You're chasing a call across modules — one-page matrix + sequence diagrams for the hot paths (dispatch → SMS → tech scan → equipment install, Stripe webhook → ERPNext, customer portal → hub proxy) |
| [data-model.md](data-model.md) | You're about to touch ERPNext: Customer, Service Location, Service Subscription, Service Equipment, Sales Invoice, Payment Entry. Also covers the "Lead to Live" customer flow | | [data-model.md](data-model.md) | You're about to touch ERPNext: Customer, Service Location, Service Subscription, Service Equipment, Sales Invoice, Payment Entry. Also covers the "Lead to Live" customer flow |
| [app-design.md](app-design.md) | You're building frontend. Feature-sliced layout, Vue/Quasar patterns, Pinia, router, theming, Storybook conventions | | [app-design.md](app-design.md) | You're building frontend. Feature-sliced layout, Vue/Quasar patterns, Pinia, router, theming, Storybook conventions |

View File

@ -0,0 +1,329 @@
# Module Interactions
> The cross-cutting "who talks to whom" reference for the Gigafibre FSM
> codebase. Every feature doc links here for dependency questions instead of
> duplicating a matrix of its own.
>
> Sibling docs: [overview.md](overview.md) (containers, networks, Traefik) ·
> [data-model.md](data-model.md) (ERPNext doctypes).
---
## 1. Purpose
The codebase is split into a handful of frontends (`apps/`), a Node.js API
gateway (`services/targo-hub/`), a couple of side-car services, and a long tail
of external integrations (ERPNext, Stripe, Twilio, GenieACS, etc.). Any given
feature crosses three or four of these layers, which means the interesting
question is rarely "what does module X do" but "when module X does its job,
who does it call, and who reacts to the side-effect?".
This document is the single authoritative answer to that question. Feature
docs in [../features/](../features/README.md) describe a capability end-to-end
and link back here for the cross-module picture. If you are adding a new
module or moving an existing call, update this file first.
---
## 2. Module catalog
### 2.1 Apps (frontends)
| Module | Host / Route | Owns | Feature doc |
|---|---|---|---|
| `apps/ops` | `erp.gigafibre.ca/ops/#/*` | Staff PWA — Dispatch, Clients, Tickets, Équipe, Rapports, OCR, Téléphonie, Agent Flows, Réseau, Settings. Also serves the tech mobile tree `/j/*` (same bundle, different layout). Routes registered in [`apps/ops/src/router/index.js`](../../apps/ops/src/router/index.js). | [billing-payments.md](../features/billing-payments.md) · [cpe-management.md](../features/cpe-management.md) · [vision-ocr.md](../features/vision-ocr.md) · [flow-editor.md](../features/flow-editor.md) |
| `apps/client` | `portal.gigafibre.ca/#/*` | Customer portal PWA — Dashboard, Invoices, Tickets, Messages, Account, Catalog/Cart, payment return landings. Hash router, routes in [`apps/client/src/router/index.js`](../../apps/client/src/router/index.js). Plan A: passwordless magic-link JWT, no Authentik. | [billing-payments.md §4](../features/billing-payments.md) |
| `apps/field` | `/field/` (legacy) | Original Quasar tech app. Being retired in favour of `apps/ops` `/j/*` + SMS magic-link. See §8. | — |
| `apps/website` | `www.gigafibre.ca` | React + Vite marketing site. Lead-capture form posts to targo-hub `/api/checkout` / `/api/order`. Built from [`apps/website/`](../../apps/website/). | — |
| `apps/portal` | (Traefik only) | Not a runtime. Holds [`traefik-client-portal.yml`](../../apps/portal/traefik-client-portal.yml) which permanently redirects the legacy host `client.gigafibre.ca``portal.gigafibre.ca`, so stale SMS and bookmarks keep working. | — |
### 2.2 Services (backends we own)
| Module | Host / Port | Owns | Notes |
|---|---|---|---|
| `services/targo-hub` | `msg.gigafibre.ca:3300` | API gateway / monolith. Routes registered in [`services/targo-hub/server.js`](../../services/targo-hub/server.js). Every external integration (Twilio, Stripe, Mailjet, Gemini, GenieACS, Oktopus, Traccar) is reached *through* the hub — frontends never hit those directly. | Lib catalog below. |
| `services/modem-bridge` | `:3301` (internal) | Playwright + headless Chromium driving TP-Link XX230v web UI to read encrypted TR-181 parameters the vendor exposes only via client-side JS. Token auth, internal network only. | [`services/modem-bridge/server.js`](../../services/modem-bridge/server.js) |
| `services/docuseal` | `sign.gigafibre.ca` | DocuSeal container for commercial-contract e-signature. Residential contracts use a JWT-based acceptance flow (see `lib/acceptance.js` + `lib/contracts.js`) — DocuSeal is the commercial track. | — |
| `services/legacy-db` | `10.100.80.100:3307` | Read-only MariaDB bridge into the old PHP `gestionclient` database. Used by migration scripts and a handful of lookup queries in `lib/auth.js`. | [`services/legacy-db/docker-compose.yml`](../../services/legacy-db/docker-compose.yml) |
#### targo-hub library map (`services/targo-hub/lib/`)
One file per capability; the router in `server.js` dispatches by path prefix.
| File | Mount point | Responsibility |
|---|---|---|
| `acceptance.js` | `/accept*` | Residential contract acceptance tokens — JWT sign + signed-blob storage on Quotation. |
| `address-search.js` | (lib) | Calls the Supabase `rddrjzptzhypltuzmere` RQA address database for autocomplete. |
| `agent.js` | `/agent/*` | LLM agent runtime + tool-use dispatch (reads `agent-tools.json`). |
| `ai.js` | `/ai/*` | Internal AI decision-making powered by Gemini Flash (classification, summaries, suggestions). |
| `auth.js` | `/auth/*` | Staff auth bridge — reads Authentik headers, optionally checks legacy `gestionclient` MySQL for migration. |
| `checkout.js` | `/api/catalog`, `/api/checkout`, `/api/accept-for-client`, `/api/order`, `/api/address`, `/api/otp` | Onboarding wizard: catalog → cart → OTP → order. Creates ERPNext Lead/Quotation/Customer. |
| `config.js` | (lib) | Environment variable accessor with typed defaults. |
| `contracts.js` | `/contract*` | Signed-contract lifecycle — residential JWT and commercial DocuSeal. |
| `conversation.js` | `/conversations*` | Threaded inbox: SMS, email, web chat → ERPNext Communication / HD Ticket. |
| `device-extractors.js` | (lib) | Pure functions to extract WAN/LAN/WiFi/optical facts from GenieACS device JSON. |
| `device-hosts.js` | (lib) | `Device.Hosts.Host.{n}.*` TR-181 host list parser. |
| `devices.js` | `/devices`, `/acs/*` | GenieACS NBI proxy — device lookup, presets, tasks, diagnostic summary. |
| `dispatch.js` | `/dispatch*` | Dispatch Job assignment scoring, tech-to-job matching. |
| `email-templates.js` | (lib) | HTML templates for OTP, magic-link, invoice email. |
| `email.js` | (lib) | SMTP transport (Mailjet `in-v3.mailjet.com` per `config.js`). |
| `flow-api.js` | `/flow/start`, `/flow/advance`, `/flow/complete`, `/flow/event`, `/flow/runs/*` | HTTP endpoints for the flow runtime. |
| `flow-runtime.js` | (lib) | Execution engine for Flow Templates — `dispatchEvent()` is how modules fire triggers. |
| `flow-templates.js` | `/flow/templates*` | CRUD for Flow Template doctype. |
| `helpers.js` | (lib) | HTTP client, ERPNext REST wrapper (`erpFetch`, `erpRequest`), JSON response helper. |
| `ical.js` | `/dispatch/ical-token/:tech`, `/dispatch/calendar/:tech.ics` | Signed-token iCal feed — lets techs subscribe their jobs in Apple/Google Calendar. |
| `magic-link.js` | `/magic-link*` | JWT sign/verify + one-time magic-link issuance. |
| `modem-bridge.js` | `/modem*` | Thin client for the modem-bridge sidecar. |
| `network-intel.js` | `/network/*` | InfluxDB + Grafana log analysis → Gemini summarization. |
| `oktopus-mqtt.js` | (worker) | MQTT subscriber to the Oktopus broker — compensates for their broken events-controller hook. |
| `oktopus.js` | `/oktopus/*` | Oktopus CE REST API — TR-369/USP device preauth and provisioning. |
| `olt-snmp.js` | `/olt*` | SNMP poller for ONU status / optical power on the OLTs. |
| `otp.js` | (lib) | One-time-password issuance + email/SMS delivery (used by checkout + portal). |
| `outage-monitor.js` | `/webhook/kuma` | Uptime-Kuma webhook ingest; cross-references OLT poller to synthesize mass-outage alerts. |
| `payments.js` | `/payments*`, `/webhook/stripe` | Stripe Checkout session creation, customer portal links, webhook verification, Payment Entry reconciliation, PPA cron. Fires `on_payment_received` flow trigger. |
| `pbx.js` | `/webhook/3cx/call-event` | 3CX call-event webhook → ERPNext Communication. |
| `portal-auth.js` | `/portal/*` | Passwordless magic-link endpoint `POST /portal/request-link` — rate-limited, anti-enumeration. |
| `project-templates.js` | (lib) | Hard-coded job-template catalogue (fibre_install, etc.) for dispatch. |
| `provision.js` | `/provision/*` | Device provisioning orchestrator — GenieACS preset push + Oktopus preauth. |
| `referral.js` | `/api/referral/*` | Referral credit validation + application, backing the wizard. |
| `reports.js` | `/reports*` | GL / revenue / tax / A/R reports, Frappe PostgreSQL queries. |
| `sse.js` | `/sse`, `/broadcast` | Server-Sent Events fan-out. Topics: `customer:*`, `conversations`, `dispatch`, etc. |
| `tech-absence-sms.js` | (worker) | Auto-notify customers when their tech is absent. |
| `tech-mobile.js` | `/t/*` | Lightweight mobile tech page (predecessor of `/j/*`). |
| `telephony.js` | `/telephony/*` | Routr / identity SIP pool management. |
| `traccar.js` | `/traccar*` | Traccar REST API proxy (GPS fleet tracker). |
| `twilio.js` | `/send/sms`, `/webhook/twilio/sms-*`, `/voice/*` | Twilio SMS send + inbound webhook + Voice tokens + TwiML. |
| `vision.js` | `/vision/barcodes`, `/vision/equipment`, `/vision/invoice` | Gemini 2.5 Flash vision OCR endpoints. |
| `voice-agent.js` | `/voice/inbound`, `/voice/gather`, `/voice/connect-agent`, `/voice/ws` | Twilio IVR + agent runtime with Media Streams WebSocket. |
### 2.3 External integrations
| Provider | Used from | Channel | Purpose |
|---|---|---|---|
| ERPNext v16 / Frappe | hub (`helpers.erpFetch`), ops (nginx proxy), client (via hub) | REST `/api/resource`, `/api/method` | Source of truth for Customer, Subscription, Invoice, Ticket, Dispatch Job, etc. |
| Authentik SSO | Traefik ForwardAuth middleware | Header injection | Guards staff surfaces (`/ops/`, `n8n.`, `hub.`). |
| Twilio | `lib/twilio.js`, `lib/voice-agent.js` | SMS / Voice (REST + webhook + Media Streams WS) | SMS magic-links, OTP, IVR, Voice tokens. |
| Stripe | `lib/payments.js` | REST + webhook (`Stripe-Signature`) | Checkout sessions, Billing Portal, card-on-file, Klarna BNPL. |
| Mailjet | `lib/email.js` (SMTP `in-v3.mailjet.com`) | SMTP | Transactional email (OTP, invoices, magic-links). |
| GenieACS | `lib/devices.js`, `helpers.nbiRequest` | NBI REST / TR-069 | Modem provisioning + diagnostics (legacy CPE fleet). |
| Oktopus CE | `lib/oktopus.js`, `lib/oktopus-mqtt.js` | REST + MQTT (TR-369/USP) | New CPE fleet. |
| Traccar | `lib/traccar.js` | REST + WebSocket | GPS breadcrumbs for field techs. |
| Gemini 2.5 Flash | `lib/vision.js`, `lib/ai.js` | REST (`generativelanguage.googleapis.com`) | Vision OCR + internal AI. |
| n8n | (via Authentik header proxy) | REST | Workflow automation — long-running batch jobs. |
| DocuSeal | `sign.gigafibre.ca` | REST + webhook | Commercial contract e-signature. |
---
## 3. Interaction matrix
Row = caller, column = callee. Cell = primary channel (`REST`, `SSE`, `WS`,
`MQTT`, `SMS`, `SMTP`, `TR-069`, `TR-369`, `Webhook`, `JWT`, `ForwardAuth`,
iCal). Empty = no direct interaction (use the hub as a relay).
| caller ↓ / callee → | ops | client | field | hub | ERPNext | GenieACS | Oktopus | Traccar | Twilio | Stripe | Gemini | Authentik | DocuSeal |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| **ops** | — | — | — | REST + SSE | REST (nginx proxy w/ token) | — (via hub) | — (via hub) | WS (via hub proxy) | — (via hub) | — (via hub) | — (via hub `/vision`) | ForwardAuth | — |
| **client** | — | — | — | REST + SSE | — (via hub) | — | — | — | — (via hub) | — (via hub `checkout-link`) | — (via hub) | JWT (magic-link, not Authentik) | — |
| **field** | — | — | — | REST | REST (legacy, token) | — | — | — | — | — | — | — | — |
| **website** | — | — | — | REST (`/api/checkout`, `/api/order`) | — (via hub) | — | — | — | — | — | — | — | — |
| **hub** | SSE | SSE | — | — | REST (`erpFetch`, `Authorization: token ...`) | REST (NBI `nbiRequest`) | REST + MQTT | REST + WS | SMS REST + Webhook | REST + Webhook (`Stripe-Signature`) | REST | — | REST + Webhook |
| **modem-bridge** | — | — | — | — (hub calls it, `:3301`) | — | — | — | — | — | — | — | — | — |
| **ERPNext** | — | — | — | — (fire-and-forget HTTP to hub `/flow/*` from server scripts) | — | — | — | — | — | — | — | — | — |
| **Traefik** | Forwards | Forwards | Forwards | Forwards | Forwards | — | — | — | — | — | — | ForwardAuth | — |
| **Twilio** | — | — | — | Webhook (`/webhook/twilio/*`) | — | — | — | — | — | — | — | — | — |
| **Stripe** | — | — | — | Webhook (`/webhook/stripe`) | — | — | — | — | — | — | — | — | — |
| **DocuSeal**| — | — | — | Webhook (`/contract/*`) | — | — | — | — | — | — | — | — | — |
---
## 4. Canonical flows
Six flows cover 90 % of the interactions. Each is grounded in the hub lib
file that drives it.
### 4.1 New customer onboarding
```text
apps/website (React lead form)
│ POST /api/order (lib/checkout.js)
targo-hub ──► address-search.js ──► Supabase RQA
──► otp.js ──► Twilio SMS + Mailjet SMTP
──► erpFetch ──► ERPNext (Lead → Quotation → Customer)
──► payments.js ──► Stripe Checkout session
│ user pays
Stripe ──webhook──► hub /webhook/stripe (lib/payments.js, Stripe-Signature verify)
├─► ERPNext Payment Entry
├─► flow-runtime.dispatchEvent('on_payment_received')
│ ├─► contracts.js (DocuSeal or JWT accept)
│ └─► dispatch.js (create Dispatch Job)
└─► sse.broadcast('customer:<name>', ...)
```
Feature doc: [billing-payments.md](../features/billing-payments.md).
### 4.2 Tech dispatches a job
```text
apps/ops /dispatch (DispatchPage.vue)
│ drag-drop assignment, POST /dispatch/assign (lib/dispatch.js)
hub ──► ERPNext Dispatch Job (scoring weights in dispatch.js)
──► twilio.sendSms → customer cell: "/j/<JWT>"
Tech opens /j/<JWT> on phone (apps/ops/src/modules/tech/*)
│ JWT verified by magic-link.js
│ GPS check-in → traccar.js → Traccar REST → WS breadcrumbs back to ops
│ Scan device barcode → useScanner → /vision/barcodes → Gemini
│ Mark complete → flow-runtime.dispatchEvent('on_job_complete')
ops SSE topic `dispatch` refreshes the Kanban
```
Feature docs: [cpe-management.md](../features/cpe-management.md) (device scan) ·
[vision-ocr.md](../features/vision-ocr.md) (OCR pipeline) ·
[flow-editor.md](../features/flow-editor.md) (post-job trigger).
### 4.3 Customer pays an invoice from the portal
```text
apps/client /#/invoices/INV-0000123 (InvoiceDetailPage.vue)
│ click "Payer maintenant"
│ POST /billing/checkout-link (lib/payments.js)
hub ──► Stripe Checkout session (mode=payment, client_reference_id=INV-…)
│ browser redirects to Stripe, user pays
Stripe ──webhook──► hub /webhook/stripe
├─► ERPNext Payment Entry
├─► flow-runtime.dispatchEvent('on_payment_received')
└─► sse.broadcast('customer:<name>', 'invoice.paid', …)
Both apps/ops (cust detail) and apps/client (dashboard) update live over SSE.
```
### 4.4 Customer requests support
```text
apps/client /#/messages (MessagesPage.vue)
│ POST /conversations (lib/conversation.js)
hub ──► ERPNext HD Ticket (erpFetch)
──► sse.broadcast('conversations', …)
apps/ops /tickets (TicketsPage.vue) — live refresh
│ staff replies → conversation.handle → ERPNext Communication
hub ──► twilio.sendSms (if customer channel = SMS) or email.js (if email)
```
### 4.5 Modem diagnostic from ops
```text
apps/ops /network (NetworkPage.vue)
│ GET /devices/lookup?serial=… (lib/devices.js)
hub ──► GenieACS NBI (helpers.nbiRequest)
│ if TP-Link XX230v → ask modem-bridge :3301 for encrypted params
modem-bridge ──► Playwright headless Chromium ──► modem web UI (172.17.x.x:443)
──► decrypted TR-181 JSON back to hub
hub ──► device-extractors.summarizeDevice → consolidated JSON
ops UI renders WAN / LAN / WiFi / Optical; SSE topic `customer:<name>`
updates any other ops tab watching the same client.
```
Feature doc: [cpe-management.md](../features/cpe-management.md).
### 4.6 Passwordless login (customer)
```text
apps/client /#/login (LoginPage.vue)
│ POST /portal/request-link (lib/portal-auth.js)
│ rate-limited, anti-enumeration (constant-time response)
hub ──► otp.js / magic-link.js (JWT HS256, 24h exp)
──► twilio.sendSms ("Votre lien: https://portal.gigafibre.ca/#/?token=…")
──► email.js (SMTP Mailjet)
customer clicks link → apps/client router guard hydrates store from token
→ store.customerId set → dashboard loads
```
---
## 5. Authentication matrix
| Surface | Mechanism | Source of trust | Notes |
|---|---|---|---|
| Staff apps (ops, n8n, hub UI) | Authentik ForwardAuth via Traefik `authentik-client@file` middleware | `id.gigafibre.ca` session cookie | Headers `X-Authentik-Email`, `X-Authentik-Groups` injected into upstream. |
| Customer portal (`apps/client`) | 24 h HS256 JWT (magic-link) | `lib/magic-link.js` secret | No Authentik; no password form reachable (Traefik redirects legacy `/login`). |
| Tech mobile (`/j/*` inside `apps/ops`) | 24 h HS256 JWT sent by SMS | Same secret | Same JWT primitive as portal. |
| hub → ERPNext | `Authorization: token <key>:<secret>` (Frappe API token) | ERPNext Administrator token — never regenerate ("Generate Keys" in ERPNext UI breaks the hub) | Used by `helpers.erpFetch`. |
| ops → ERPNext (frontend) | nginx same-origin reverse proxy injects the same token server-side | Token stays server-side; browser never sees it | See `apps/ops/infra/`. |
| Webhooks | Per-provider signature verification | `Stripe-Signature`, `X-Twilio-Signature`, DocuSeal shared secret | Rejected synchronously before any side-effect. |
| `modem-bridge` | Shared bearer token | env var on both hub + bridge containers | Internal network only, not exposed via Traefik. |
| Traccar | Bearer token preferred (fall-back Basic) | env var on hub | See `lib/traccar.js` header note. |
---
## 6. Data ownership
Source of truth per entity. "Hub cache" means the hub keeps a read-through
copy; the authoritative write goes to the listed owner.
| Entity | Source of truth | Hub role | Frontend read path |
|---|---|---|---|
| Customer | ERPNext `Customer` | thin pass-through (`erpFetch`) | ops: direct via nginx proxy · client: via hub |
| Address (Service Location) | ERPNext `Service Location` | pass-through; `address-search.js` also calls Supabase RQA for autocomplete | — |
| Subscription | ERPNext `Subscription` | pass-through | — |
| Invoice | ERPNext `Sales Invoice` | pass-through + `/vision/invoice` OCR | — |
| Payment | ERPNext `Payment Entry` | written from Stripe webhook in `lib/payments.js` | — |
| Ticket | ERPNext `HD Ticket` | written from `lib/conversation.js` | — |
| Dispatch Job | ERPNext `Dispatch Job` (custom doctype) | written from `lib/dispatch.js` | ops SSE topic `dispatch` |
| Technician | ERPNext `Technician` (custom) | pass-through | — |
| Service Equipment | ERPNext `Service Equipment` (custom) | pass-through | — |
| Tag | ERPNext `Tag` | pass-through | — |
| Flow Template | ERPNext `Flow Template` (custom) | CRUD in `lib/flow-templates.js` | ops `/agent-flows` |
| Flow Run | ERPNext `Flow Run` (custom) | written from `lib/flow-runtime.js` | ops `/agent-flows` |
| **Exceptions** | | | |
| Live modem state (WAN IP, SNR, host list) | GenieACS / Oktopus | hub never persists — always fetched live | — |
| GPS breadcrumbs | Traccar | hub proxies WS + REST | ops `/dispatch` map |
| JWT sessions | hub process memory + browser `localStorage` | — | no server-side session store |
---
## 7. Real-time channels
| Channel | Transport | Producer | Consumer | Topic / payload |
|---|---|---|---|---|
| Ops / portal live updates | SSE | `lib/sse.js` | `apps/ops`, `apps/client` | `customer:<name>`, `conversations`, `dispatch`, ad-hoc |
| TR-369 device events | MQTT | Oktopus broker | `lib/oktopus-mqtt.js` worker | device-online, heartbeat |
| GPS breadcrumbs | WebSocket | Traccar | `lib/traccar.js` proxy → ops dispatch | `deviceId`, `latitude`, `longitude`, `speed` |
| Twilio Media Streams | WebSocket upgrade on hub `/voice/ws` | Twilio | `lib/voice-agent.js` | raw audio frames for IVR agent |
| SSE keep-alive | server-pushed `: ping` | `lib/sse.js` 25 s interval | browsers | heartbeat |
---
## 8. Retired / in-retirement
Do not build new features on these. Linked target is where the replacement
lives.
| Retiring | Replacement | Status | Notes |
|---|---|---|---|
| `auth.targo.ca` | `id.gigafibre.ca` (Authentik) | active migration | ForwardAuth middleware already on the new host. |
| `apps/field` | `apps/ops` `/j/*` tree | in retirement | Same bundle, different layout — removes the separate deploy surface. |
| `dispatch-app` (legacy standalone) | `apps/ops` `/dispatch` | retired | Feature parity reached; decommission pending DNS cleanup. |
| `client.gigafibre.ca` | `portal.gigafibre.ca` | retired | Traefik permanent-redirect via [`apps/portal/traefik-client-portal.yml`](../../apps/portal/traefik-client-portal.yml). |
| Frappe `/login` form for customers | `/portal/request-link` magic-link | retired | Legacy MD5 hashes not migrated — customers forced through the new flow. |
| Ollama local `llama3.2-vision` | `lib/vision.js` → Gemini 2.5 Flash | retired | Ops VM has no GPU. |
---
Back to [docs/README.md](../README.md).

View File

@ -6,9 +6,16 @@ modes. Open the one that matches the feature you're changing.
| Doc | Owns | | Doc | Owns |
|---|---| |---|---|
| [dispatch.md](dispatch.md) | Ops dispatch board: drag-and-drop scheduling, tech assignment with skill tags, travel-time optimization, magic-link SMS issuance, live SSE updates |
| [tech-mobile.md](tech-mobile.md) | Field tech app (three surfaces: SSR `/t/{jwt}`, transitional `apps/field/`, unified `/ops/#/j/*`). Native camera → Gemini scanner, equipment install/remove, JWT auth, offline queue |
| [customer-portal.md](customer-portal.md) | Passwordless customer self-service at `portal.gigafibre.ca`: magic-link email (24h JWT), invoice + ticket view, Stripe-linked payment flows |
| [billing-payments.md](billing-payments.md) | Stripe integration (Checkout, Billing Portal, webhook), subscription lifecycle, invoice generation, payment reconciliation, PPA (Plan de paiement automatique), Klarna BNPL | | [billing-payments.md](billing-payments.md) | Stripe integration (Checkout, Billing Portal, webhook), subscription lifecycle, invoice generation, payment reconciliation, PPA (Plan de paiement automatique), Klarna BNPL |
| [cpe-management.md](cpe-management.md) | CPE fleet: GenieACS (TR-069), Oktopus (TR-369), provisioning, diagnostics, TP-Link XX230v / Deco deep probe, migration plan | | [cpe-management.md](cpe-management.md) | CPE fleet: GenieACS (TR-069), Oktopus (TR-369), provisioning, diagnostics, TP-Link XX230v / Deco deep probe via modem-bridge, migration plan |
| [vision-ocr.md](vision-ocr.md) | Camera-based scanning via Gemini 2.5 Flash — barcode, equipment label, invoice OCR. Hub endpoints `/vision/*`, `useScanner` composable, offline queue, AI_API_KEY rotation policy, ERPNext relationships triggered by a scan | | [vision-ocr.md](vision-ocr.md) | Camera-based scanning via Gemini 2.5 Flash — barcode, equipment label, invoice OCR. Hub endpoints `/vision/*`, `useScanner` composable, offline queue, AI_API_KEY rotation policy, ERPNext relationships triggered by a scan |
| [flow-editor.md](flow-editor.md) | Agent-flows module: Flow Template + Flow Run doctypes, trigger catalogue, step types, runtime contract, UI editor at `/ops/#/agent-flows` | | [flow-editor.md](flow-editor.md) | Agent-flows module: Flow Template + Flow Run doctypes, trigger catalogue, step types, runtime contract, UI editor at `/ops/#/agent-flows` |
**Cross-module map:** see
[../architecture/module-interactions.md](../architecture/module-interactions.md)
for the full call graph + sequence diagrams.
Back to [docs/README.md](../README.md) · [roadmap.md](../roadmap.md) Back to [docs/README.md](../README.md) · [roadmap.md](../roadmap.md)

View File

@ -0,0 +1,707 @@
# Customer Portal — Architecture & Integration Guide
> Authoritative reference for the Gigafibre customer portal: the Vue 3 +
> Quasar SPA at `portal.gigafibre.ca`, its passwordless magic-link
> authentication, and the standalone `nginx:alpine` container that serves
> it. Companion document to [../architecture/overview.md](../architecture/overview.md)
> and [../architecture/data-model.md](../architecture/data-model.md).
>
> Last updated: 2026-04-22. Plan A (magic-link login replacing ERPNext
> `/login`) shipped today. See commits `2b04e6b` and `7ac9a58` for the
> deploy-topology cutover.
---
## 1. Goals & Principles
The customer portal is the only front door a residential or commercial
customer ever sees. It replaces the legacy PHP portal and the ERPNext
`/login` password form; staff continue to use Authentik (`ops.gigafibre.ca`
/ `erp.gigafibre.ca`) and never touch the portal host.
Design tenets:
1. **Passwordless by default.** The customer types an email or phone, the
hub mails/SMSes a 24 h JWT, the SPA decodes it client-side. No password,
no session cookie, no Authentik for customers — rationale documented in
`~/.claude/projects/-Users-louispaul-Documents-testap/memory/project_portal_auth.md`
(legacy passwords are unsalted MD5 and cannot be trusted).
2. **Anti-enumeration by contract.** `POST /portal/request-link` *always*
returns `HTTP 200 {"ok":true, "sent":[…]}`. An unknown identifier returns
`sent: []`. An attacker cannot use the endpoint to probe whether an email
or phone is in our customer database.
3. **Rate limit the send surface.** 3 requests per 15-minute window per
identifier (case-insensitive). Survives restart loss gracefully — the
Map is in-memory and repopulates on the next abuse attempt.
4. **Both channels, best effort.** A customer with both email and phone
gets the link on both — SMS failure does not block email, and vice versa.
The `sent: [...]` array tells the UI which channels actually delivered
so it can display redacted destinations.
5. **Two routes, one SPA.** `portal.gigafibre.ca` is the canonical host.
`client.gigafibre.ca` is a 307 redirect at the Traefik layer so stale
bookmarks, SMS notifications from before the cutover, and old invoice
links still land on the right place.
6. **Hash router, not path router.** The SPA uses `createWebHashHistory`
so the token survives behind the `#` and never reaches nginx. Also lets
the single `index.html` serve every route without per-path nginx rules.
7. **Retire, don't migrate.** ERPNext's `/login` is dead for customers —
there is no "staff impersonation" path in the passwordless flow. Staff
who need to act as a customer use the Ops "Send magic link" action on
the client-detail page (implemented via `POST /portal/request-link`).
---
## 2. Topology & Deployment
### 2.1 Hosts & routing
```
┌───────────────────────────────────────┐
│ Traefik (erp.gigafibre.ca:443) │
│ *.gigafibre.ca wildcard → 96.125.196.67│
└───────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌───────────────────────┐ ┌────────────────────┐ ┌──────────────┐
│ portal.gigafibre.ca │ │ client.gigafibre.ca│ │ erp.*/ops.* │
│ (canonical host) │ │ (legacy alias) │ │ (staff) │
├───────────────────────┤ ├────────────────────┤ ├──────────────┤
│ docker container │ │ Traefik dynamic │ │ ERPNext │
│ client-portal │ │ route — 307 to │ │ frontend + │
│ image nginx:alpine │ │ portal.gigafibre.ca│ │ Authentik │
│ bind-mounts │ │ /$1 (preserves │ │ forwardAuth │
│ /opt/client-app/ │ │ path) │ │ │
│ ─ index.html │ │ │ │ NO customer │
│ ─ assets/* │ │ (traefik-client- │ │ /login form │
│ ─ nginx.conf │ │ portal.yml) │ │ — endpoint is│
│ ─ docker-compose │ │ │ │ blocked by │
└───────────────────────┘ └────────────────────┘ │ redirect │
└──────────────┘
│ magic-link JWT + REST
│ (msg.gigafibre.ca)
┌──────────────────┐
│ targo-hub │
│ portal-auth.js │
│ magic-link.js │
└──────────────────┘
│ resource REST
┌──────────────────┐
│ ERPNext backend │
│ (Customer, Sales │
│ Invoice, Issue) │
└──────────────────┘
```
### 2.2 Two distinct Vue builds used to coexist — no more
Before 2026-04-22 the same Vue SPA was built twice: once with
`base=/assets/client-app/` and dropped into the ERPNext public folder so
ERPNext's frontend would serve it at `client.gigafibre.ca/assets/client-app/`;
a second build ran for local dev. That's retired — we now build with
`base=/` and ship to a standalone nginx container.
`apps/client/quasar.config.js` keeps `/assets/client-app/` as the fallback
so a local `quasar dev` still works, but production always overrides with
`DEPLOY_BASE=/`:
```js
// apps/client/quasar.config.js
extendViteConf (viteConf) {
viteConf.base = process.env.DEPLOY_BASE || '/assets/client-app/'
}
```
```bash
# apps/client/deploy.sh (prod build)
VITE_ERP_TOKEN="…" DEPLOY_BASE=/ npx quasar build -m pwa
```
### 2.3 `deploy.sh` — atomic swap + the inode gotcha
The deploy does three things remotely:
1. Upload the fresh build into a staging directory (`mktemp -d
/opt/client-app.new.XXXXXX`).
2. `mv` the live `/opt/client-app` aside (`/opt/client-app.bak.<epoch>`),
then `mv` the staging dir onto `/opt/client-app`. This is atomic —
nginx never sees a half-written `index.html` referencing hashed assets
that haven't finished uploading.
3. Keep the three most recent backups, delete older ones.
Then the non-obvious part:
> **Docker bind mounts follow the inode, not the path.** When `docker run`
> records a bind mount, it resolves `/opt/client-app` once and pins that
> inode. After the atomic `mv` swap, the container is still pointed at the
> *old* directory (now `/opt/client-app.bak.<epoch>`) — so you'd serve
> stale content from the backup until you recreate the mount.
>
> Fix: `docker restart client-portal` at the end of the deploy. The
> restart re-resolves the bind and the container picks up the new inode.
That `docker restart` line is the entire reason `deploy.sh` needed an
update today; previously we relied on the bind mount "just reflecting the
new files" which only works when you edit them in place, not when you
swap the entire directory.
### 2.4 Local build only
```bash
./deploy.sh local # build with DEPLOY_BASE=/ into dist/pwa/, no upload
```
Handy when you want to test the PWA manifest / service worker path
behaviour before shipping.
---
## 3. File Inventory
### 3.1 Front-end — `apps/client/`
| Path | Responsibility |
|--------------------------------------------------|--------------------------------------------------------------------------|
| `deploy.sh` | Build + rsync + atomic swap + `docker restart client-portal` |
| `quasar.config.js` | Vite base override (`DEPLOY_BASE`), hash router, PWA manifest |
| `src/App.vue` | Root — calls `store.init()` once on mount |
| `src/router/index.js` | Routes + navigation guard (pushes unauthenticated to `/login`) |
| `src/layouts/PortalLayout.vue` | Drawer + header + cart badge, shows customer name |
| `src/stores/customer.js` | Pinia store: `hydrateFromToken`, JWT decode, `stripTokenFromUrl`, `init` |
| `src/stores/cart.js` | Pinia store for the public catalog cart |
| `src/composables/useMagicToken.js` | Read-through: `{authenticated, expired, customerId}` for payment pages |
| `src/composables/useSSE.js` | EventSource wrapper for `msg.gigafibre.ca/sse` (topic-based) |
| `src/composables/useFormatters.js` | `formatDate`, `formatMoney`, `formatShortDate`, `formatPrice` |
| `src/composables/useOTP.js` | OTP send/verify for catalog checkout existing-customer path |
| `src/composables/useAddressSearch.js` | RQA address autocomplete wrapper |
| `src/composables/usePolling.js` | Generic polling helper |
| `src/api/auth-portal.js` | `requestPortalLink()` — posts to hub `/portal/request-link` |
| `src/api/auth.js` | `authFetch()` wraps ERPNext REST with service token (`VITE_ERP_TOKEN`) |
| `src/api/portal.js` | ERPNext REST client: invoices, tickets, profile, service locations, comms |
| `src/api/payments.js` | Hub client: balance, methods, checkout, setup card, billing portal, PPA |
| `src/api/catalog.js` | Hub client: catalog, checkout, address search, OTP |
| `src/pages/*.vue` | One page per route (see §4) |
### 3.2 Back-end — `services/targo-hub/lib/`
| Path | Responsibility |
|----------------------|--------------------------------------------------------------------------------------|
| `portal-auth.js` | `POST /portal/request-link` — lookup, mint JWT, dispatch SMS + email, rate limit |
| `magic-link.js` | `generateCustomerToken()` — HS256 JWT with `scope=customer`, 24 h TTL |
| `config.js` | `CLIENT_PORTAL_URL`, `JWT_SECRET` — wired to `portal.gigafibre.ca` by default |
| `helpers.js` | `lookupCustomerByPhone()` — last-10-digit match across `cell_phone`, `tel_home`, `tel_office` |
| `twilio.js` | `sendSmsInternal()` — used for the SMS channel |
| `email.js` | `sendEmail()` — used for the email channel |
| `checkout.js` / `payments.js` | Stripe endpoints that the portal hits from `InvoiceDetailPage` |
| `conversation.js` | SSE topic `conversations` + `customer:<id>` for real-time inbox updates |
### 3.3 Traefik — `apps/portal/`
| Path | Responsibility |
|------------------------------|----------------------------------------------------------------------------|
| `traefik-client-portal.yml` | Dynamic route: `Host(\`client.gigafibre.ca\`)` → 307 to `portal.…/$1` |
| `deploy-portal.sh` | `scp` the YAML to `/opt/traefik/dynamic/client-portal.yml` + smoke-test |
---
## 4. Routes & Pages
The router uses `createWebHashHistory`, so every URL above the portal
root looks like `https://portal.gigafibre.ca/#/<path>`. Route paths in
French match the customer-facing UI vocabulary (`/me`, `/panier`,
`/paiement/merci`, `/commande/confirmation`).
| Path | Name | Purpose | Endpoints |
|-------------------------------|---------------------|---------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `/login` | `login` | Passwordless login form — email or phone input, sent state, rate banner | hub `POST /portal/request-link` |
| `/` | `dashboard` | "Bonjour, {name}" + unpaid count + open tickets + 5 most recent invoices | ERPNext `Sales Invoice`, `Issue`; hub `POST /payments/checkout` |
| `/invoices` | `invoices` | Paginated table of Sales Invoices with download PDF action | ERPNext `Sales Invoice`, `frappe.utils.print_format.download_pdf` |
| `/invoices/:name` | `invoice-detail` | Single invoice: summary, lines, taxes, pay CTA (card + Klarna), PDF | ERPNext `Sales Invoice/:name`, `printview.get_html_and_style`; hub `POST /payments/checkout-invoice` |
| `/tickets` | `tickets` | Support ticket list + create dialog | ERPNext `Issue` (list + create) |
| `/tickets/:name` | `ticket-detail` | Conversation thread + reply box | ERPNext `Issue/:name`, `Communication` (list + post) |
| `/messages` | `messages` | All communications (SMS / Email / Phone) grouped by ticket or medium | ERPNext `Communication` (direct + via Issues); hub SSE topic `customer:<id>` |
| `/me` | `account` | Customer info, service locations + monthly total, saved cards, PPA toggle | ERPNext `Customer/:name`, `Service Location`, `Service Subscription`; hub `/payments/balance`, `/payments/methods`, `/payments/setup`, `/payments/portal`, `/payments/toggle-ppa` |
| `/catalogue` | `catalog` | Public add-on catalog (Internet / Téléphonie / Bundle / Équipement) | hub `GET /api/catalog` |
| `/panier` | `cart` | Cart review, install scheduling, OTP flow for existing customers | hub `POST /api/otp/send`, `POST /api/otp/verify`, `POST /api/address-search`, `POST /api/checkout` |
| `/commande/confirmation` | `order-success` | Post-checkout confirmation with order number + install notice | hub `GET /api/order/:id` |
| `/paiement/merci` | `payment-success` | Stripe return (session mode) — "Paiement reçu" + link back to invoice | — (return landing only) |
| `/paiement/annule` | `payment-cancel` | Stripe return when the user backs out | — (return landing only) |
| `/paiement/carte-ajoutee` | `payment-card-added`| Stripe Setup Intent return — confirms the card is saved, nudges PPA | — (return landing only) |
### 4.1 Public routes (skip the nav guard)
```js
// apps/client/src/router/index.js
const PUBLIC_ROUTES = new Set([
'login',
'catalog', 'cart', 'order-success',
'payment-success', 'payment-cancel', 'payment-card-added',
])
```
Payment returns stay public so a customer whose JWT has expired still
sees a human confirmation (plus a re-auth CTA) rather than a bounce.
The catalog and cart are public so unauthenticated prospects can shop
add-ons — the OTP flow in the cart escalates to auth only at checkout.
---
## 5. Authentication Flow
### 5.1 Sequence
```
Customer Login Page targo-hub ERPNext Email/SMS
│ │ │ │ │
│── types email/phone ──▶ │ │ │
│ │── POST /portal/ │ │ │
│ │ request-link ─────────▶│ │ │
│ │ │── lookupCustomer ─────▶│ │
│ │ │◀── Customer row ───────│ │
│ │ │ │
│ │ │── generateCustomerToken (HS256, 24h) ───────│
│ │ │── sendSmsInternal ─────────────────────────▶│
│ │ │── sendEmail ───────────────────────────────▶│
│ │◀── 200 {ok:true, │ │ │
│ │ sent:[sms,email], │ │ │
│ │ redacted:[…]} ────────│ │ │
│◀── "Lien envoyé!" ────│ │ │ │
│ │
│ (customer opens SMS / email) │
│◀───────────────────── https://portal.gigafibre.ca/#/?token=<JWT> ──────────────────────────────│
│ │
│── click ──▶ SPA loads │
│ └─ router guard: to.query.token → store.hydrateFromToken() │
│ └─ JWT parts[1] base64-decoded, exp checked, scope==='customer' │
│ └─ customerStore.customerId = payload.sub │
│ └─ stripTokenFromUrl() (history.replaceState) │
│ └─ dashboard renders │
```
### 5.2 JWT shape
Minted by `generateCustomerToken(customerId, customerName, email, ttlHours=24)`
in `services/targo-hub/lib/magic-link.js`:
```json
{
"alg": "HS256", "typ": "JWT"
}
.
{
"sub": "C-00042",
"scope": "customer",
"name": "Jean Tremblay",
"email": "jean@example.com",
"iat": 1713798000,
"exp": 1713884400
}
```
Signed HS256 with `cfg.JWT_SECRET` (shared only between the hub processes
— the portal never holds the secret). The portal trusts the signature
implicitly because the token arrived over HTTPS via a channel the user
already controls (their own inbox or SMS). Any tampering breaks
signature verification the next time the user clicks the link; the
currently-loaded SPA never re-verifies.
### 5.3 Identifier resolution
`portal-auth.js → lookupCustomer(raw)` handles three forms:
| Input shape | Lookup order |
|------------------------|-----------------------------------------------------------------------------------------------|
| Contains `@` | `Customer.email_id``Customer.email_billing` (both lowercased) |
| Matches `^(C-|CUST-|GI-)/i` or `^\d{4,7}$` | `Customer.legacy_customer_id` → direct `Customer.name` fetch |
| Anything else (phone) | `helpers.lookupCustomerByPhone()` — last 10 digits matched against `cell_phone`/`tel_home`/`tel_office` |
Returned record always includes `name`, `customer_name`, `cell_phone`,
`tel_home`, `email_id`, `email_billing` so the sender knows which channels
are available.
### 5.4 URL hygiene after hydration
`stores/customer.js → stripTokenFromUrl()` uses `history.replaceState` to
rewrite the URL without `?token=` as soon as the store is hydrated. This
keeps the token out of:
- Browser history (back button won't show the token).
- Bookmarks ("add this page to bookmarks" captures the stripped URL).
- Third-party referer headers (external links on subsequent pages).
The JWT still lives in the Pinia store for the rest of the tab's life;
a hard refresh reloads the store from…nothing, and the router guard
bounces back to `/login` unless the URL carries a token or a previous
hydration put `customerId` into `localStorage` (the store does not
persist — this is intentional; a refresh is a fresh auth attempt).
### 5.5 Subsequent calls
Two backends, two auth styles:
- **ERPNext REST** (`BASE_URL/api/resource/…`, `/api/method/…`): served
through `authFetch()` in `src/api/auth.js`, which attaches
`Authorization: token <VITE_ERP_TOKEN>`. The token is a fixed service
key baked in at build time; it's not the customer's JWT. The customer's
identity is enforced at the query layer by always filtering on
`customer = store.customerId` from the store.
- **Hub** (`msg.gigafibre.ca/payments/*`, `/api/*`, `/sse`): currently
called unauthenticated. Endpoints that need customer context receive
`customer` as a body/path parameter. The hub trusts the portal origin.
Upgrading these endpoints to verify the magic-link JWT is tracked as
a hardening follow-up (see §10).
---
## 6. Anti-Enumeration Contract
`POST /portal/request-link` adheres strictly to the following response
matrix. Callers (portal UI, staff-ops tools, any future integration)
must treat `200 OK` as the *only* success signal and must *not* branch
on the identity of sent channels to decide whether the account exists.
| Situation | HTTP | Body |
|-----------------------------------|------|---------------------------------------------------------------------------------|
| Unknown identifier | 200 | `{"ok":true, "sent":[]}` |
| Known, only email on file | 200 | `{"ok":true, "sent":["email"], "redacted":["j***n@example.com"]}` |
| Known, only phone on file | 200 | `{"ok":true, "sent":["sms"], "redacted":["***-***-1234"]}` |
| Known, both channels | 200 | `{"ok":true, "sent":["sms","email"], "redacted":["***-***-1234","j***n@…"]}` |
| Known, both channels, both failed | 200 | `{"ok":true, "sent":[]}` (logged internally as delivery failure) |
| Empty body | 400 | `{"error":"identifier_required"}` |
| Rate limit hit | 429 | `{"error":"rate_limit", "message":"Trop de tentatives. Réessayez dans N min.", "retry_after_sec":N}` |
Two downstream rules follow:
1. **The login page shows "Lien envoyé"** even on `sent: []`. The copy
says "Si votre compte existe, un lien vous a été envoyé" — the timing
and the UI are identical to the happy path. A typo'd email looks
indistinguishable from a genuine send.
2. **The internal log still records the miss** (`log('Portal
request-link: no match for "…"')`) so ops can spot fat-finger traffic
vs. probing. The log is not served publicly.
Redaction rules (`redactEmail`, `redactPhone`):
- Email: keep first + last char of local part, mask the middle, keep
the domain (`j***n@example.com`).
- Phone: `***-***-XXXX` where `XXXX` is the last 4 digits of the raw
`cell_phone`/`tel_home` field (not the E.164-normalized one).
---
## 7. Rate Limiting
A single `Map<identifier, {count, windowStart}>` in the hub process
enforces 3 requests per 15 minutes (`RATE_LIMIT_MAX`, `RATE_LIMIT_WINDOW_MS`
in `portal-auth.js`). The key is `rawIdentifier.trim().toLowerCase()` so
`jean@example.com` and `JEAN@Example.com` share a bucket.
Lifecycle:
```js
// portal-auth.js
function checkRateLimit (key) {
const now = Date.now()
const entry = rateLimits.get(key)
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
rateLimits.set(key, { count: 1, windowStart: now })
return { ok: true }
}
if (entry.count >= RATE_LIMIT_MAX) {
return { ok: false, retryAfterMs: RATE_LIMIT_WINDOW_MS - (now - entry.windowStart) }
}
entry.count++
return { ok: true }
}
// Cull every 15 min so the Map stays small
setInterval(() => { /* drop entries older than window */ }, RATE_LIMIT_WINDOW_MS).unref()
```
### 7.1 Failure modes we accept
- **Process restart** wipes the Map. Worst case a throttled attacker gets
their window reset — still bounded by Twilio + Mailjet rate limits, and
the internal `Portal request-link` log surfaces the abuse for ops.
- **Hub horizontal scale** would duplicate the Map per replica. Today the
hub is a single container so the Map is authoritative; if we scale out,
move to Redis (tracked under the hub hardening line in
[../roadmap.md](../roadmap.md)).
- **Identifier rotation** (attacker types slightly different strings):
mitigated by Twilio/Mailjet per-destination throttles that kick in
downstream — the attacker can't out-run our carrier tier.
### 7.2 Error surfacing to the UI
`requestPortalLink()` rethrows the 429 as a typed error:
```js
// src/api/auth-portal.js
if (r.status === 429) {
const err = new Error(data.message || 'Trop de tentatives. Réessayez plus tard.')
err.code = 'rate_limit'
err.retryAfterSec = data.retry_after_sec || 900
throw err
}
```
`LoginPage.vue` displays the message in an orange banner (`q-banner
bg-orange-1`) without clearing the input — the user can read the message
and adjust their identifier.
---
## 8. Cross-Module References
### 8.1 Billing — Stripe Checkout + Klarna
`InvoiceDetailPage.vue` exposes both payment rails:
```js
payInvoice('card') → hub POST /payments/checkout-invoice { payment_method: 'card' }
payInvoice('klarna') → hub POST /payments/checkout-invoice { payment_method: 'klarna' }
```
The hub returns a hosted Checkout URL; the SPA does
`window.location.href = url`. Stripe's return URLs come back to
`/#/paiement/merci?invoice=…`, `/#/paiement/annule?invoice=…`, and
`/#/paiement/carte-ajoutee` (Setup Intent flow). All three live under
`PUBLIC_ROUTES` so an expired token still gets a civilized landing.
Full lifecycle: [billing-payments.md](billing-payments.md) — especially
§4 "Flux de paiement public" and §7 "Configuration & secrets" for Stripe
keys and webhook wiring.
### 8.2 Messaging — Server-Sent Events
`MessagesPage.vue` subscribes to `msg.gigafibre.ca/sse?topics=customer:<id>`
via `useSSE`. The hub broadcasts `conversations` and per-customer topics
from `lib/conversation.js` (see lines 6870 of that file); every inbound
SMS or email handled by the hub fires a broadcast, and the portal
reloads `fetchAllCommunications` on receipt and shows a toast for
inbound-direction messages.
The SPA's SSE connection is resilient: `useSSE` implements exponential
backoff (1 s → 30 s) on disconnect. Read path details in
`apps/client/src/composables/useSSE.js`.
### 8.3 Tickets — read-only window into ERPNext HD Ticket
`TicketsPage` and `TicketDetailPage` hit the stock ERPNext `Issue`
doctype. Creation goes through `POST /api/resource/Issue` with
`customer`, `subject`, `description`, `issue_type='Support'`,
`priority='Medium'`. Replies from the portal create a `Communication`
tied to the Issue; staff see them in the Ops CRM pane. Cross-ref
`docs/architecture/data-model.md` for the full `Issue` field list.
### 8.4 Catalog — shared SKUs with the quote wizard
`CatalogPage` lists the same Items the sales wizard exposes — Internet
plans, phone service, bundles, equipment. Prices and `requires_visit`
flags come from ERPNext Items via the hub's `/api/catalog` endpoint.
Add-on purchases placed through `/panier` become `Sales Order` drafts
that the dispatch queue picks up. See `billing-payments.md` §2
"App Frappe custom — `gigafibre_utils`" for the shared SKU catalog
source of truth.
### 8.5 Contract acceptance
Two separate tracks, both documented elsewhere:
- **Residential JWT** (promotion framing, 24 h link, same style as this
portal's auth) — `services/targo-hub/lib/contracts.js`. See
`billing-payments.md` and memory `project_contract_acceptance.md`.
- **Commercial DocuSeal**`services/targo-hub/lib/acceptance.js`,
documented in the same memory file.
Neither track reuses the portal's magic-link JWT today; the
`scope=customer` token is strictly for browsing. A signed contract
produces its own short-lived acceptance JWT minted by `contracts.js`.
### 8.6 Tech mobile app
The field tech interface lives at `msg.gigafibre.ca/t/<tech-token>`
— a separate HS256 token scope (`scope='all'` or per-job) minted by
`magic-link.js`. It shares the hub's HS256 primitive (`signJwt`) with
the customer portal but not the token itself. There is no customer-facing
touchpoint on the tech app; cross-ref is kept here only so future
refactors remember the two scopes sign with the same secret.
---
## 9. Deploy & Operations
### 9.1 Ship a portal change
```bash
cd apps/client
./deploy.sh # build PWA + rsync + flip + docker restart
```
What the script prints on success:
```
Deployed. Backup: /opt/client-app.bak.1713798123
Done! Customer Portal: https://portal.gigafibre.ca/
Legacy alias (302): https://client.gigafibre.ca/
```
### 9.2 Ship a Traefik redirect change (`client.gigafibre.ca`)
```bash
cd apps/portal
./deploy-portal.sh # scp YAML to /opt/traefik/dynamic/ + smoke test
```
Traefik auto-reloads anything under `/opt/traefik/dynamic/` — no restart.
The script also curls `https://client.gigafibre.ca/login` and prints the
HTTP code so you know whether Let's Encrypt has provisioned the cert yet
(first request may return 000 while the cert is being issued).
### 9.3 Roll back
```bash
ssh root@96.125.196.67
ls -dt /opt/client-app.bak.* # pick the timestamp you want
mv /opt/client-app /opt/client-app.broken
mv /opt/client-app.bak.<epoch> /opt/client-app
docker restart client-portal # same inode trap as deploy
```
`deploy.sh` keeps the three most recent backups (`ls -dt … | tail -n +4
| xargs -r rm -rf`), which is usually enough to roll back a same-day mistake.
### 9.4 Smoke tests
```bash
# SPA shell reachable
curl -sI https://portal.gigafibre.ca/ | head -5
# Legacy redirect preserves path
curl -sI https://client.gigafibre.ca/invoices | grep -iE 'location|http/'
# expect: HTTP/2 307
# expect: location: https://portal.gigafibre.ca/invoices
# Magic-link endpoint: anti-enumeration contract
curl -s -X POST https://msg.gigafibre.ca/portal/request-link \
-H 'Content-Type: application/json' \
-d '{"identifier":"does-not-exist@example.com"}'
# expect: {"ok":true,"sent":[]}
# Rate limit (send the same identifier 4 times quickly)
for i in 1 2 3 4; do
curl -s -X POST https://msg.gigafibre.ca/portal/request-link \
-H 'Content-Type: application/json' \
-d '{"identifier":"rl-test@example.com"}' ; echo
done
# expect 4th call: 429 + rate_limit body
```
### 9.5 Cache bust
The Quasar PWA service worker is configured with `skipWaiting: true` +
`clientsClaim: true` + `cleanupOutdatedCaches: true`. A user who's been
sitting on a stale tab usually picks up the new build on next nav. For
a hard bust, append `?v=<epoch>` to the URL or instruct the user to
do a reload; the hash router handles the query string transparently.
---
## 10. Failure Modes
| Symptom | Likely cause | Remedy |
|-----------------------------------------------------|---------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|
| User clicks magic link, lands on `/login` | JWT expired (>24 h) | Re-request a link. Payment-return pages show a banner with a one-click re-auth button |
| User clicks magic link, lands on dashboard briefly, then bounces to `/login` on refresh | Store not persisted; token already stripped from URL | Expected — refresh is a fresh auth. Re-request or click from the original email again |
| "Lien envoyé" but no SMS arrives | Twilio rejected the number (no `cell_phone`/`tel_home` on file, or number invalid) | Check `docker logs targo-hub | grep 'Portal link'` — the log reports `sent via email` vs `NONE` |
| "Lien envoyé" but no email arrives | Mailjet rate limit / bounce | Same log line. Investigate Mailjet dashboard for the destination |
| Dashboard renders blank after click | `VITE_ERP_TOKEN` broken; ERPNext returns 401 | Browser console shows `[portal] ERPNext returned 401 — check VITE_ERP_TOKEN`. Rebuild with the right token |
| `client.gigafibre.ca` serves the old portal | Traefik dynamic config not reloaded / not placed in `/opt/traefik/dynamic/` | `scp` via `deploy-portal.sh`, then `docker logs --tail 50 traefik` |
| Deploy succeeds, but users still see old UI | Docker bind mount still pinned to backup inode | `ssh … docker restart client-portal` (deploy.sh does this; if you copied files manually, restart manually) |
| SSE never connects in `/messages` | CSP or mixed-content block on `msg.gigafibre.ca` | Check browser console — the nginx `Content-Security-Policy` must allow `connect-src https://msg.gigafibre.ca` |
| Hash router breaks behind corporate proxy | Proxy strips the `#` fragment | Issue a plain `/login` link (no token) — users self-request magic link from there |
| 429 loop for a well-meaning user | Same identifier typed 4 times inside 15 min | Wait the window out, or restart the hub container to flush the Map |
### 10.1 CSP for the hash router
The nginx container's config (`/opt/client-app/nginx.conf`, preserved
across deploys by the `cp docker-compose.yml nginx.conf` lines in
`deploy.sh`) must include at minimum:
```
connect-src 'self' https://msg.gigafibre.ca https://erp.gigafibre.ca;
```
The first permits hub SSE and REST; the second permits ERPNext REST via
`authFetch`. If either is missing, the SPA loads but every data call fails.
---
## 11. Retirement Notes
### 11.1 ERPNext `/login` — retired for customers
- The form is still reachable at `erp.gigafibre.ca/login` but is gated
behind Authentik forwardAuth — non-staff accounts cannot authenticate.
- `client.gigafibre.ca/login` (the old direct ERPNext frontend) no
longer serves the form because the whole host is 307-redirected to
`portal.gigafibre.ca/login`.
- No migration of legacy MD5 password hashes is required. Ever. See
memory `project_portal_auth.md` for rationale.
### 11.2 `client.gigafibre.ca` — legacy alias, not canonical
- All outbound communications (email templates, SMS, invoice footers,
QR codes on PDFs) now use `https://portal.gigafibre.ca/#/…`. See
`project_invoice_qr.md` memory for the QR wiring.
- The redirect in `traefik-client-portal.yml` is currently a `302`
(`permanent: false`) while the cutover is still warm — we can flip
to `301` once we're confident nothing else points at the old host.
Tracking this in [../roadmap.md](../roadmap.md).
- DNS: `*.gigafibre.ca` wildcard already resolves to `96.125.196.67`.
Let's Encrypt auto-provisions the cert for both hosts.
### 11.3 Authentik — not a factor for customers
Staff continue to use Authentik forwardAuth for Ops (`ops.gigafibre.ca`)
and ERPNext (`erp.gigafibre.ca`). The portal host has no Authentik
middleware; the JWT is the sole credential. Memory files
`reference_authentik.md` and `reference_authentik_client.md` cover
the staff side.
---
## 12. Glossary
- **Magic link**`https://portal.gigafibre.ca/#/?token=<JWT>`. The only
authenticated entry point for customers. Minted by the hub, delivered
via SMS + email.
- **Customer JWT** — HS256 token with `scope=customer`, 24 h TTL,
`sub=<Customer.name>`. Decoded client-side; never re-verified.
- **Anti-enumeration** — The contract that every `POST /portal/request-link`
returns 200 regardless of whether the identifier matched.
- **Hash router** — Vue Router's `createWebHashHistory` mode. URL paths
live after `#`, so the server always serves the same `index.html`.
- **Inode trap** — The Docker bind-mount behaviour where `mv`-swapping a
directory leaves the container pointed at the old inode. Solved by
`docker restart client-portal`.
- **Legacy alias**`client.gigafibre.ca`. Still resolves, still has a
cert, but 307-redirects every request to the canonical host.
- **Service token**`VITE_ERP_TOKEN`: a fixed ERPNext API key baked
into the SPA bundle. Identifies the *portal* to ERPNext; not the
customer. Customer identity is enforced at the query layer.
---
*Last updated: 2026-04-22. Owners: Targo Platform team
(<louis@targo.ca>). Related roadmap items: hub JWT verification on
`/payments/*`, flip `client.gigafibre.ca` redirect to 301, Redis-backed
rate limit for multi-replica hub. See [../roadmap.md](../roadmap.md).*
---
Back to [docs/README.md](../README.md) · [roadmap.md](../roadmap.md)

332
docs/features/dispatch.md Normal file
View File

@ -0,0 +1,332 @@
# Dispatch
> Real-time technician scheduling, work-order creation, and field publishing for the Ops PWA. Largest module in `apps/ops/` — the single page where CSRs turn incoming service requests into scheduled work on a technician's calendar.
## 1. Goals & Principles
Dispatch is the live control surface for every field intervention. It replaces the legacy PHP `dispatch-app` (slated for retirement per [../architecture/overview.md](../architecture/overview.md)) with a Vue 3 / Pinia timeline bound directly to ERPNext Dispatch Job and Dispatch Technician DocTypes.
Principles enforced in the code:
- **Single source of truth is ERPNext.** Every drag, resize, tag toggle issues a `PUT /api/resource/Dispatch Job/{name}` through `src/api/dispatch.js`. The Pinia store (`src/stores/dispatch.js`) only holds a mapped view; on reload it rehydrates from Frappe.
- **Optimistic UI + undo stack.** `useUndo` snapshots mutations so keyboard `Cmd+Z` can roll back a mistake before ERPNext ever replies.
- **Match skill, not cost.** Per `feedback_dispatch_tags.md` convention, tags carry a level 1-5 and auto-dispatch picks the lowest adequate match — experts are preserved for jobs that require them.
- **Push, don't poll.** Timeline stays in sync via SSE topics `dispatch` and `network` from `targo-hub` — tech-absence SMS, outage alerts, OLT up/down events all land as toasts without a refresh.
- **Human and material resources share one grid.** A `Dispatch Technician` row is either a human (with a phone / user) or a material asset (Véhicule, Nacelle, OTDR, Fusionneuse…) — the same drag-drop logic applies.
- **Work is not visible to techs until it is published.** The `published` flag on a Dispatch Job gates both the mobile page (`/t/{token}`) and the iCal feed.
## 2. File Inventory
### Front-end (Ops PWA)
| Path | Responsibility |
| --- | --- |
| `apps/ops/src/pages/DispatchPage.vue` | Top-level page, composes all composables, owns SSE connection, renders timeline / week / month switcher plus inline map panel. |
| `apps/ops/src/modules/dispatch/components/TimelineRow.vue` | One row per resource — renders shift, absence, travel, assist, ghost and job segments; emits drag / drop / resize events. |
| `apps/ops/src/modules/dispatch/components/WeekCalendar.vue` | 7-day grid view with absence chips, ghost recurrences and day-load bars; handles planning mode shifts and on-call bands. |
| `apps/ops/src/modules/dispatch/components/MonthCalendar.vue` | Month overview with per-day tech-count badges; shows selected tech's availability when in planning mode. |
| `apps/ops/src/modules/dispatch/components/BottomPanel.vue` | Unassigned jobs tray with lasso selection, batch assign and auto-distribute buttons. |
| `apps/ops/src/modules/dispatch/components/RightPanel.vue` | Details side-panel for a selected job — edit, move, geofix, unassign, set end date, remove assistant, assign pending, update tags. |
| `apps/ops/src/modules/dispatch/components/JobEditModal.vue` | Inline edit of title, address, note, duration, priority and tags. |
| `apps/ops/src/modules/dispatch/components/WoCreateModal.vue` | "Nouveau work order" modal — runs `rankTechs` + `refineWithDrivingTimes` on the top 3 candidates. |
| `apps/ops/src/modules/dispatch/components/CreateOfferModal.vue` | Creates a job offer in broadcast / targeted / pool mode with a pricing preset. |
| `apps/ops/src/modules/dispatch/components/OfferPoolPanel.vue` | Live offer feed with status chips (open, pending, accepted, expired, cancelled). |
| `apps/ops/src/modules/dispatch/components/PublishScheduleModal.vue` | "Publier & envoyer l'horaire par SMS" — publishes jobs, builds per-tech magic link + webcal iCal URL, sends via Twilio. |
| `apps/ops/src/modules/dispatch/components/SbModal.vue` | Generic overlay modal with header / body / footer slots. |
| `apps/ops/src/modules/dispatch/components/SbContextMenu.vue` | Positioned context-menu wrapper used for right-click menus on techs and jobs. |
| `apps/ops/src/modules/dispatch/components/MapPanel.vue` | Standalone map panel component — present but **not imported** by `DispatchPage.vue`, which uses an inline Mapbox block instead. |
| `apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue` | Client for `/dispatch/suggest-slots` (hub endpoint). Present but **not imported** by `DispatchPage.vue` — kept for future "Find me a time" entry point. |
| `apps/ops/src/stores/dispatch.js` | Pinia store — maps ERPNext rows to UI-friendly shape, rebuilds tech queues, triggers GPS polling. |
| `apps/ops/src/config/dispatch.js` | Row height constant, resource icon map (Véhicule, Nacelle, Fusionneuse…), re-exports `HUB_SSE_URL`. |
| `apps/ops/src/api/dispatch.js` | All Frappe REST calls — `fetchTechnicians`, `fetchJobsFast`, `updateJob`, `publishJobs`, `createJob`, `createTech`. |
| `apps/ops/src/composables/useScheduler.js` | Drives period navigation, busy lanes, ghost materialization. |
| `apps/ops/src/composables/useDragDrop.js` | Drag sources / drop targets for jobs, techs and assistants. |
| `apps/ops/src/composables/useAutoDispatch.js` | Client-side candidate ranking mirroring the hub algorithm. |
| `apps/ops/src/composables/useJobOffers.js` | Offer-pool state + `PRICING_PRESETS`. |
| `apps/ops/src/composables/useAbsenceResize.js` | Drag-to-resize absence bands. |
| `apps/ops/src/composables/useTechManagement.js` | Create / delete / reassign technicians. |
| `apps/ops/src/composables/useTagManagement.js` | Tag editor bindings for techs and jobs. |
| `apps/ops/src/composables/useContextMenus.js` | Tech and job right-click menus. |
| `apps/ops/src/composables/useSelection.js` | Lasso selection in the bottom panel. |
| `apps/ops/src/composables/useBottomPanel.js` | Collapsed / pinned state of the unassigned tray. |
| `apps/ops/src/composables/usePeriodNavigation.js` | 3-period buffer and infinite horizontal scroll. |
| `apps/ops/src/composables/useResourceFilter.js` | Human / material / group filtering. |
| `apps/ops/src/composables/useMap.js` | Mapbox GL layer and marker lifecycle. |
| `apps/ops/src/composables/useUndo.js` | Per-mutation snapshots for `Cmd+Z`. |
| `apps/ops/src/composables/useAddressSearch.js` | Mapbox geocoder wrapper used by Wo and Job modals. |
| `apps/ops/src/components/dispatch/NlpInput.vue` | Natural-language "type-to-dispatch" shortcut. |
### Back-end (targo-hub)
| Path | Responsibility |
| --- | --- |
| `services/targo-hub/server.js` | Routes `/dispatch/*` to `dispatch.js`, `/dispatch/ical-token/:id` and `/dispatch/calendar/:id.ics` to `ical.js`, `/t/*` to `tech-mobile.js`, `/traccar/*` to `traccar.js`, `/magic-link/*` to `magic-link.js`. |
| `services/targo-hub/lib/dispatch.js` | `POST /dispatch/best-tech`, `POST /dispatch/suggest-slots`, `POST /dispatch/create-job`. Implements `rankTechs`, `getTechsWithLoad`, `enrichWithGps`, `suggestSlots`. |
| `services/targo-hub/lib/ical.js` | HMAC-SHA256 token signing, VCALENDAR builder with `America/Toronto` VTIMEZONE, status + priority mapping, RRULE passthrough. |
| `services/targo-hub/lib/traccar.js` | Bearer-auth proxy to the Traccar GPS server with a 60s device cache. |
| `services/targo-hub/lib/magic-link.js` | `POST /magic-link/generate`, `POST /magic-link/verify`, `POST /magic-link/refresh` — JWT tokens, default 72h TTL, job-level and all-jobs-for-tech variants. |
| `services/targo-hub/lib/tech-mobile.js` | Server-rendered mobile page at `/t/{token}` with status update, scan, vision, equipment and catalog endpoints. |
| `services/targo-hub/lib/tech-absence-sms.js` | Inbound Twilio SMS → Gemini Flash NLU (regex fallback) → `setTechAbsence` / `clearTechAbsence` → SSE `tech-absence` broadcast. |
| `services/targo-hub/lib/sse.js` | Topic fan-out used by the `dispatch` and `network` channels. |
### ERPNext (Frappe custom DocTypes)
| Path | Responsibility |
| --- | --- |
| `erpnext/setup_fsm_doctypes.py` | Creates / patches custom fields on Dispatch Job and Dispatch Technician. |
## 3. Data Model
### Dispatch Job
Core Frappe fields plus these custom fields (from `erpnext/setup_fsm_doctypes.py`):
| Field | Type | Purpose |
| --- | --- | --- |
| `customer` | Link → Customer | Billing-side link. |
| `service_location` | Link → Service Location | Physical address + geocoded coordinates. |
| `job_type` | Select | Installation / Réparation / Maintenance / Retrait / Dépannage / Autre. |
| `source_issue` | Link → Issue | Origin ticket, when the job came from Support. |
| `depends_on` | Link → Dispatch Job | Blocks scheduling until the dependency is done. |
| `parent_job` | Link → Dispatch Job | Multi-step workflow (with `step_order`). |
| `on_open_webhook`, `on_close_webhook` | Data | Flow-editor callbacks — see [flow-editor.md](flow-editor.md). |
| `equipment_items`, `materials_used`, `checklist`, `photos` | Table | Child tables fed by the mobile tech page. |
| `actual_start`, `actual_end`, `travel_time_min` | Datetime / Int | Field-recorded metrics. |
| `completion_notes`, `customer_signature` | Text / Attach | End-of-job capture. |
| `published` | Check | Gates visibility on the mobile page and iCal feed. |
| `is_recurring`, `recurrence_rule`, `recurrence_end`, `pause_periods`, `template_id` | Mixed | RRULE-based repeating jobs (materialized on demand). |
| `continuous` | Check | Emergency jobs that may span weekends / off-days. |
| `assistants` (child table) | Table | `tech_id`, `tech_name`, `duration_h`, `note`, `pinned`. |
| `tags` (child table) | Table | `tag`, `level`, `required`. |
### Dispatch Technician
| Field | Type | Purpose |
| --- | --- | --- |
| `resource_type` | Select | `human` or `material`. |
| `resource_category` | Select | Véhicule / Outil / Salle / Équipement / Nacelle / Grue / Fusionneuse / OTDR. |
| `weekly_schedule` | Text / JSON | Parsed by `parseWeeklySchedule`. |
| `extra_shifts` | JSON (hidden) | Per-date overrides. |
| `absence_reason`, `absence_from`, `absence_until`, `absence_start_time`, `absence_end_time` | Mixed | Current absence band. |
| `traccar_device_id` | Data | Links to a Traccar GPS device. |
| `tags` (child table) | Table | `tag`, `level`. |
Full field definitions live in [../architecture/data-model.md](../architecture/data-model.md).
## 4. UI Surfaces
All French strings below are pulled directly from `DispatchPage.vue`.
### Top bar
"Ressources" (resource filter), "Filtres" (tag filter), "Aujourd'hui" (jump-to-today), "Jour / Semaine / Mois" view toggle, "Planning" mode for availability editing, "Carte" for the inline map, "Publier" for the publish modal, "+ WO" for the work-order creator, "ERP" status pill.
### Timeline view (`TimelineRow.vue`)
One row per resource. Each row renders stacked segments:
| Segment type | Source | Notes |
| --- | --- | --- |
| `shift` | `weeklySchedule` + `extraShifts` | Active working window; drop targets enabled. |
| `absence` | `absence_from..absence_until` or weekly "off" days | Resizable via `useAbsenceResize`. |
| `travel` | Computed between consecutive jobs | Mapbox Directions API; non-draggable. |
| `assist` | `assistants[]` on another tech's job | Pinned assists cannot be dragged away. |
| `ghost` | Unmaterialized recurrence | Click → materialize concrete Dispatch Job. |
| `job` | Assigned, scheduled Dispatch Job | Primary drag source. |
Drag sources: tech avatar (to split shifts), job blocks (to move / reassign), pinned assist blocks (to nudge helper). Emits `select-tech`, `ctx-tech`, `job-dragstart`, `timeline-drop`, `block-move`, `block-resize`, `absence-resize`, `ghost-click`, `ghost-materialize`.
### Week view (`WeekCalendar.vue`)
Seven-day grid per resource. Distinguishes `isExplicitAbsent` (a typed absence record) from `isScheduleOff` (non-working weekday). In planning mode it draws availability bands and on-call shift overlays on top.
### Month view (`MonthCalendar.vue`)
Month grid with per-day badges showing how many techs are available. In planning mode a sidebar surfaces the selected tech's weekly schedule and extra shifts.
### Bottom panel (`BottomPanel.vue`)
Unassigned jobs tray. Supports:
- Lasso selection (click-drag to select multiple)
- Batch assign to a tech
- Auto-distribute across available techs using `useAutoDispatch`
- Collapse / pin state via `useBottomPanel`
### Right panel (`RightPanel.vue`)
Details + pending-request slide-in. Emits `edit`, `move`, `geofix`, `unassign`, `set-end-date`, `remove-assistant`, `assign-pending`, `update-tags`.
### Inline map
DispatchPage renders its own Mapbox panel using `useMap` and `MAPBOX_TOKEN` from `config/erpnext.js`. `MapPanel.vue` exists in the module but is not currently imported — kept for a possible future extraction.
### Offer pool (`OfferPoolPanel.vue`, `CreateOfferModal.vue`)
Three offer modes surface as icon toggles in `CreateOfferModal`:
| Mode | Behaviour |
| --- | --- |
| `broadcast` | Blast to every matching tech; first accepter wins. |
| `targeted` | Single named recipient. |
| `pool` | A named candidate list; first to accept wins. |
Pricing is composed from `PRICING_PRESETS` (in `useJobOffers`) as `displacement$ + hourlyRate$/h × duration`. Offer status chips: `open`, `pending`, `accepted`, `expired`, `cancelled`.
### Context menu (`SbContextMenu.vue`)
Right-click on a tech row or job block opens a context menu wired through `useContextMenus` — quick reassign, cancel, open in ERP, copy magic link, copy iCal URL.
### Drag-drop rules (enforced in `useDragDrop`)
- Drop on an active shift / within absence band = allowed (toggle absence).
- Drop on a non-working weekday = blocked unless `continuous=1`.
- Drop on a tech whose tags don't meet `tags.required` levels = warning toast but still allowed (CSR override).
- `Escape` cancels any in-flight drag.
- `Delete` while a job is selected opens the unassign confirmation.
- `Cmd+Z` walks back the `useUndo` snapshot stack.
## 5. Key Flows
### 5.1 Create a work order
1. CSR clicks `+ WO``WoCreateModal.vue` opens.
2. Address input auto-geocodes via `useAddressSearch` (Mapbox geocoder).
3. On submit, `WoCreateModal` calls `findBestTech()` which locally runs the ranking, then refines the top 3 with live driving times.
4. `createJob` (in `src/api/dispatch.js`) POSTs to ERPNext.
5. The store prepends the new job; SSE fan-out notifies other open Ops tabs.
### 5.2 Suggest the best tech (server side)
`POST /dispatch/best-tech` on `targo-hub` (`lib/dispatch.js`):
- `rankTechs` computes a score per candidate using SCORE_WEIGHTS — `proximityMultiplier=4`, `proximityMax=100` km, `loadMultiplier=30`, `overloadPenalty=500`, `gpsFreshnessBonus=20`.
- Proximity uses a Euclidean km approximation at Montreal latitude (fast enough for ranking).
- `getTechsWithLoad` tallies today's scheduled hours; anything above the configured cap triggers the overload penalty.
- `enrichWithGps` adds a freshness bonus when a recent Traccar fix exists.
### 5.3 Suggest time slots
`POST /dispatch/suggest-slots` (`lib/dispatch.js`) returns up to N best windows across all techs:
- 7-day horizon, capped at 2 slots per tech to diversify suggestions.
- Default shift 08:00-17:00 (overridable per tech).
- 15-minute travel buffer inserted before/after existing jobs.
- `SuggestSlotsDialog.vue` is the front-end client — present in the repo but not yet wired into `DispatchPage.vue`; the current path is tech selection via `WoCreateModal` or manual drag.
### 5.4 Offer pool
1. CSR opens `CreateOfferModal`, picks mode + pricing preset.
2. Offer is persisted through `useJobOffers`.
3. `OfferPoolPanel` subscribes to offer events and updates chip status.
4. Accepting techs fire a state transition that converts the offer into an assigned Dispatch Job.
### 5.5 Publish & SMS schedule
`PublishScheduleModal.vue` — title "Publier & envoyer l'horaire par SMS":
1. Operator selects a tech + date range.
2. `publishJobs(jobNames)` from `src/api/dispatch.js` PUTs `published: 1` on each job in parallel.
3. For each tech, the modal fetches:
- A magic link via `POST /magic-link/generate` (targo-hub, default 72h TTL) → produces the `/t/{token}` URL that opens the mobile tech page.
- An iCal token via `GET /dispatch/ical-token/{techId}` → builds a `webcal://` URL pointing at `/dispatch/calendar/{techId}.ics?token=...`.
4. Twilio sends two SMS:
- "Mes tâches: {link}" — the magic link.
- "Ajouter à mon calendrier: {webcal url}" — the iCal subscription URL.
5. `sendTestSms` is invoked with `reference_doctype = Dispatch Technician` so every send is logged against the tech in ERPNext.
### 5.6 Absence via SMS (inbound)
`services/targo-hub/lib/tech-absence-sms.js`:
1. Twilio inbound webhook hits `handleAbsenceSms`.
2. `lookupTechByPhone` matches the last 10 digits of the sender against Dispatch Technicians.
3. Gemini 2.5 Flash (OpenAI-compatible endpoint) parses intent + dates; a regex fallback catches "absent / malade / sick / formation / vacances" with simple date ranges if the LLM is down.
4. `setTechAbsence` or `clearTechAbsence` PUTs to Dispatch Technician and broadcasts SSE `tech-absence` on the `dispatch` topic.
5. Ops timelines update in-place; the operator sees a toast. A French confirmation SMS is returned to the tech.
### 5.7 Recurring jobs (ghosts → materialized)
- A template job carries `is_recurring=1` and an RFC 5545 `recurrence_rule` (e.g. `FREQ=WEEKLY;BYDAY=SA,SU`).
- `WeekCalendar` and `TimelineRow` render upcoming occurrences as ghost segments computed client-side.
- Clicking a ghost fires `ghost-materialize`, creating a concrete Dispatch Job with `template_id` set — subsequent edits diverge from the template.
- `pause_periods` carves out vacation windows without deleting the template.
## 6. External Integrations
### Traccar (GPS)
`services/targo-hub/lib/traccar.js` proxies `/traccar/devices`, `/traccar/positions` and a generic GET fallback with Traccar bearer-token auth preferred over Basic. Device metadata is cached for 60 seconds. The store (`useGpsTracking`) polls positions and feeds live `gpsCoords`, `gpsSpeed`, `gpsOnline` into each tech row — these in turn power the freshness bonus in the server-side ranker. Traccar limitation noted in [../architecture/overview.md](../architecture/overview.md) #6.4: only one `deviceId` per request — poll in parallel.
### Twilio
- **Outbound**`PublishScheduleModal` calls `sendTestSms` to deliver the magic link and webcal URL per tech.
- **Inbound**`tech-absence-sms.js` owns Twilio's incoming SMS webhook and drives the absence flow above.
- Number `+14382313838` per `reference_twilio.md`.
### iCal / webcal
`services/targo-hub/lib/ical.js`:
- Token is HMAC-SHA256 over `{techId}` with `ICAL_SECRET`, truncated to 16 hex chars.
- Fetches jobs from past 7 days to future 60 days for the tech.
- Emits RFC 5545 with a full `America/Toronto` VTIMEZONE (DST rules included).
- Status: `open → TENTATIVE`, `cancelled` / `completed → CANCELLED`.
- Priority: `urgent → 1`, `medium → 5`, everything else → `9`.
- Honors `recurrence_rule` — passes the RRULE through untouched.
Subscription URL handed to techs is `webcal://msg.gigafibre.ca/dispatch/calendar/{techId}.ics?token=...` — Apple Calendar and Google Calendar both handle this.
### Mobile tech page (`/t/{token}`)
`services/targo-hub/lib/tech-mobile.js` is a lightweight server-rendered page — **not** the Ops PWA. It authenticates the tech with a JWT magic link (`verifyJwt`), lists their published jobs for the day, and offers POST endpoints:
| Route | Purpose |
| --- | --- |
| `GET /t/{token}` | Day view for the tech. |
| `POST /t/{token}/status` | Flip status (In Progress, Completed). |
| `POST /t/{token}/scan` | Record a barcode scan. |
| `POST /t/{token}/vision` | Send a photo to `/vision/barcodes` for OCR. |
| `POST /t/{token}/equip` | Install a Service Equipment row. |
| `POST /t/{token}/equip-remove` | Decommission equipment. |
| `GET /t/{token}/catalog` | Fetch the equipment catalog. |
| `GET /t/{token}/equip-list` | Fetch equipment already on site. |
The Ops PWA never opens this page — it lives alongside Dispatch to replace the retiring `apps/field` app. See [../architecture/overview.md](../architecture/overview.md) §1.
## 7. Cross-module References
- [../architecture/overview.md](../architecture/overview.md) — ecosystem map, retirement plan for the legacy dispatch-app and field app.
- [../architecture/data-model.md](../architecture/data-model.md) — canonical Dispatch Job / Dispatch Technician field list.
- [flow-editor.md](flow-editor.md) — wires `on_open_webhook` / `on_close_webhook` on Dispatch Job into workflow runs.
- [vision-ocr.md](vision-ocr.md) — barcode / equipment recognition consumed by the tech mobile page during a dispatch.
- [cpe-management.md](cpe-management.md) — "Diagnostiquer" deep-dives started from a customer card flow into the same tech assignment surface.
- [billing-payments.md](billing-payments.md) — Sales Orders that trigger installation Dispatch Jobs (`order_source`, `sales_order` fields on the job).
## 8. Failure Modes
| Symptom | Likely cause | Where to look |
| --- | --- | --- |
| Timeline loads but all rows are empty | ERPNext token revoked or Administrator "Generate Keys" clicked | `src/api/auth.js` + note in [../architecture/overview.md](../architecture/overview.md) §6.3 |
| Drops appear to work then revert on refresh | ERPNext `exc` swallowed — check console for `[API] PUT Dispatch Job/... failed` | `apps/ops/src/api/dispatch.js` |
| iCal subscription returns 401 | Wrong token or `ICAL_SECRET` changed — ask operator to re-copy the webcal URL | `services/targo-hub/lib/ical.js` |
| Tech never receives magic link SMS | Twilio rejected the number or `published=0` on the selected jobs | `PublishScheduleModal.vue` + Twilio console |
| Absence SMS ignored | Phone number not matched in Dispatch Technician (last-10-digits LIKE) | `services/targo-hub/lib/tech-absence-sms.js``lookupTechByPhone` |
| GPS dot stale / missing | Traccar token expired, cache 60s, or no `traccar_device_id` on the tech | `services/targo-hub/lib/traccar.js` |
| Suggestion always picks same tech | Overload penalty not firing — check `getTechsWithLoad` result for load cap | `services/targo-hub/lib/dispatch.js` |
| SSE toasts stop arriving | ForwardAuth dropped the session; reload to re-auth with Authentik | `apps/ops/src/pages/DispatchPage.vue` SSE setup |
| Ghost occurrences missing | RRULE parse failed client-side — malformed `recurrence_rule` | `apps/ops/src/stores/dispatch.js` `_mapJob` |
| Tech mobile page shows "not found" | JWT expired (default 72h) — operator must re-publish to regenerate | `services/targo-hub/lib/magic-link.js` |
## 9. Retirement Note
The legacy `dispatch-app` (PHP) and `apps/field` (mobile technician SPA) are slated for retirement in April-May 2026, per [../architecture/overview.md](../architecture/overview.md) §1. The replacement pairing is:
- **Ops PWA Dispatch module** — this document — for all scheduling, ranking, publishing.
- **Lightweight tech mobile page** at `/t/{token}` (see §6) for the field-side experience.
Do not add new features to `dispatch-app` or `apps/field`. Any feature request should land in `apps/ops/src/modules/dispatch/` or the tech-mobile handler in `services/targo-hub/lib/tech-mobile.js`.
---
Back to [docs/README.md](../README.md) · [roadmap.md](../roadmap.md)

View File

@ -0,0 +1,462 @@
# 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)