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:
parent
7ac9a582c6
commit
30a867a326
7
apps/field/package-lock.json
generated
7
apps/field/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
found.push(...mergeCodes(localCodes, 'photo'))
|
|
||||||
|
|
||||||
// --- Pass 3: Gemini fallback if local scan came up empty ---
|
|
||||||
if (found.length === 0) {
|
|
||||||
aiImage = await resizeImage(file, 1600, 0.92)
|
aiImage = await resizeImage(file, 1600, 0.92)
|
||||||
try {
|
|
||||||
const result = await scanBarcodesWithTimeout(aiImage, GEMINI_TIMEOUT_MS)
|
const result = await scanBarcodesWithTimeout(aiImage, SCAN_TIMEOUT_MS)
|
||||||
found.push(...mergeCodes(result.barcodes || [], 'IA'))
|
found = mergeCodes(result.barcodes || [], 'photo')
|
||||||
|
photos.value[photoIdx].codes = found
|
||||||
|
|
||||||
|
if (found.length === 0) {
|
||||||
|
error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point'
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isRetryable(e)) {
|
if (aiImage && isRetryable(e)) {
|
||||||
await offline.enqueueVisionScan({ image: aiImage })
|
await offline.enqueueVisionScan({ image: aiImage })
|
||||||
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
|
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
|
||||||
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
|
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
|
||||||
} else {
|
} else {
|
||||||
throw e
|
error.value = e.message || 'Erreur'
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,47 +17,22 @@
|
||||||
</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">
|
|
||||||
Jusqu'à 3 codes par photo · IA activée si la lecture locale échoue
|
|
||||||
</div>
|
</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' : '' }}
|
|
||||||
en attente · toucher pour réessayer
|
|
||||||
</q-chip>
|
</q-chip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -66,50 +41,31 @@
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Photo history (small thumbnails) -->
|
<!-- Error / status -->
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- ==== Manual entry ==== -->
|
|
||||||
<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>
|
||||||
|
|
|
||||||
7
apps/ops/package-lock.json
generated
7
apps/ops/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
found.push(...mergeCodes(localCodes, 'photo'))
|
|
||||||
|
|
||||||
// --- Pass 3: Gemini fallback if local scan came up empty ---
|
|
||||||
if (found.length === 0) {
|
|
||||||
aiImage = await resizeImage(file, 1600, 0.92)
|
aiImage = await resizeImage(file, 1600, 0.92)
|
||||||
try {
|
|
||||||
const result = await scanBarcodesWithTimeout(aiImage, GEMINI_TIMEOUT_MS)
|
const result = await scanBarcodesWithTimeout(aiImage, SCAN_TIMEOUT_MS)
|
||||||
found.push(...mergeCodes(result.barcodes || [], 'IA'))
|
found = mergeCodes(result.barcodes || [], 'photo')
|
||||||
|
photos.value[photoIdx].codes = found
|
||||||
|
|
||||||
|
if (found.length === 0) {
|
||||||
|
error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point'
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isRetryable(e)) {
|
if (aiImage && isRetryable(e)) {
|
||||||
await offline.enqueueVisionScan({ image: aiImage })
|
await offline.enqueueVisionScan({ image: aiImage })
|
||||||
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
|
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
|
||||||
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
|
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
|
||||||
} else {
|
} else {
|
||||||
throw e
|
error.value = e.message || 'Erreur'
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
function openInErp () { window.open(`${BASE_URL}/app/dispatch-job/${job.value.name}`, '_blank') }
|
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 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>
|
||||||
|
|
|
||||||
|
|
@ -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 field→ops
|
||||||
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,47 +46,22 @@
|
||||||
</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">
|
|
||||||
Jusqu'à {{ scanner.MAX_BARCODES }} codes par photo · IA activée si la lecture locale échoue
|
|
||||||
</div>
|
</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' : '' }}
|
|
||||||
en attente · toucher pour réessayer
|
|
||||||
</q-chip>
|
</q-chip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -95,50 +70,31 @@
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Photo history (small thumbnails) -->
|
<!-- Error / status -->
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- ==== Manual entry ==== -->
|
|
||||||
<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 = ''
|
manualCode.value = ''
|
||||||
} else {
|
|
||||||
Notify.create({ type: 'info', message: 'Code déjà ajouté' })
|
|
||||||
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>
|
||||||
|
|
|
||||||
245
docs/README.md
245
docs/README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
||||||
|
|
|
||||||
329
docs/architecture/module-interactions.md
Normal file
329
docs/architecture/module-interactions.md
Normal 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).
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
707
docs/features/customer-portal.md
Normal file
707
docs/features/customer-portal.md
Normal 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 68–70 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
332
docs/features/dispatch.md
Normal 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)
|
||||||
462
docs/features/tech-mobile.md
Normal file
462
docs/features/tech-mobile.md
Normal 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 (1–4 MB JPEG)
|
||||||
|
↓ createImageBitmap → 400px thumbnail (for instant UI preview)
|
||||||
|
↓ createImageBitmap → 1600px long-edge JPEG @ quality 0.92
|
||||||
|
↓ base64 encode (~500 KB over the wire)
|
||||||
|
↓ POST https://msg.gigafibre.ca/vision/barcodes
|
||||||
|
↓ body: { image: "data:image/jpeg;base64,…" }
|
||||||
|
↓ 200 { barcodes: ["1608K44D9E79FAFF5", "0418D6A1B2C3", "TPLG-A1…"] }
|
||||||
|
↓ onNewCode(code) callback fires per string, up to MAX_BARCODES = 5
|
||||||
|
```
|
||||||
|
|
||||||
|
**Timeout:** `SCAN_TIMEOUT_MS = 8000`. Beyond that we give up and either
|
||||||
|
(a) offer a retry, or (b) enqueue the image to the offline queue if
|
||||||
|
offline.
|
||||||
|
|
||||||
|
**Why 3 codes max:** the hub's `responseSchema` caps `barcodes` at 3 items
|
||||||
|
(see `services/targo-hub/lib/vision.js`). A single label often has 3–5
|
||||||
|
barcodes (EAN, S/N, MAC) stacked — we take the first 3 Gemini ranks as
|
||||||
|
most-confident and let the user tap the one that matches. The SPA keeps a
|
||||||
|
rolling window of 5 so a tech can re-scan without losing the prior match.
|
||||||
|
|
||||||
|
### 3c. Equipment-label mode (structured, not just strings)
|
||||||
|
|
||||||
|
When the user opens the scanner **from the "Scanner un code-barres" option
|
||||||
|
inside the equipment bottom-sheet** (not the top-level `/j/scan` page), we
|
||||||
|
hit `/vision/equipment` instead. That endpoint returns structured fields
|
||||||
|
ready to pre-fill the "Create equipment" dialog:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"equipment": [
|
||||||
|
{
|
||||||
|
"serial": "1608K44D9E79FAFF5",
|
||||||
|
"mac": "04:18:D6:A1:B2:C3",
|
||||||
|
"brand": "Nokia",
|
||||||
|
"model": "G-140W-C",
|
||||||
|
"type": "ONT"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`maxItems: 5`. Same 1600px JPEG. Same 8s timeout. Same offline queue.
|
||||||
|
|
||||||
|
### 3d. Offline queue
|
||||||
|
|
||||||
|
**Implemented in:** `apps/ops/src/stores/offline.js` (Pinia) via
|
||||||
|
`idb-keyval` at key `tech-vision-queue`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// When fetch() rejects or navigator.onLine === false:
|
||||||
|
enqueueVisionScan({ id, endpoint, imageDataUri, ts, consumer })
|
||||||
|
→ persist to IndexedDB
|
||||||
|
|
||||||
|
// On navigator.online event:
|
||||||
|
watch(isOnline, async now => {
|
||||||
|
if (now) flushQueue() // replays each scan, fires original consumer
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The consumer identifier (`'tech-scan' | 'tech-equip-scan' | …`) lets the
|
||||||
|
UI route the late result to the right dialog once the user is back online.
|
||||||
|
Scans older than 7 days are dropped on boot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The Vue SPA surface (`apps/ops/src/modules/tech/`)
|
||||||
|
|
||||||
|
Routes, defined in `apps/ops/src/router/index.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
path: '/j',
|
||||||
|
component: TechLayout,
|
||||||
|
children: [
|
||||||
|
{ path: '', name: 'tech-tasks', component: TechTasksPage },
|
||||||
|
{ path: 'job/:name', name: 'tech-job', component: TechJobDetailPage, props: true },
|
||||||
|
{ path: 'scan', name: 'tech-scan', component: TechScanPage },
|
||||||
|
{ path: 'device/:serial', name: 'tech-device', component: TechDevicePage, props: true },
|
||||||
|
{ path: 'diagnostic', name: 'tech-diag', component: TechDiagnosticPage },
|
||||||
|
{ path: 'more', name: 'tech-more', component: TechMorePage },
|
||||||
|
{ path: ':token', name: 'tech-magic', component: TechTasksPage, props: true }, // must be LAST
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
The magic-link route is deliberately the last child — Vue Router's pattern
|
||||||
|
matcher would otherwise swallow `/j/scan` as `token=scan`. If you add a
|
||||||
|
new static child, put it **above** the `:token` entry.
|
||||||
|
|
||||||
|
### 4a. `TechTasksPage.vue` — today's schedule
|
||||||
|
|
||||||
|
Fetches `Dispatch Job` rows filtered by `assigned_tech == sub` and
|
||||||
|
`scheduled_date == today` (UTC-Toronto). Groups them by status
|
||||||
|
("À venir", "En cours", "Terminée"). Each row navigates to
|
||||||
|
`/j/job/:name`.
|
||||||
|
|
||||||
|
### 4b. `TechJobDetailPage.vue` — the main tech surface
|
||||||
|
|
||||||
|
**458 lines. This is where the ported equipment UX lives — see §5.**
|
||||||
|
|
||||||
|
Three cards:
|
||||||
|
|
||||||
|
| Card | Fields | Writes? |
|
||||||
|
|---|---|---|
|
||||||
|
| **Info** | Type (Installation / Dépannage / …), Priority, Duration, Status chip, Description textarea | Yes — `saveField()` debounced 500ms PUTs to `Dispatch Job` |
|
||||||
|
| **Location** | Address (click → Google Maps GPS), contact name, contact phone (expandable, lazy-loads `Service Location`) | No — read-only |
|
||||||
|
| **Equipment** | List of `Service Equipment` linked to the job; "Ajouter un équipement" bottom-sheet | Yes — see §5 |
|
||||||
|
|
||||||
|
Bottom-of-page action buttons:
|
||||||
|
- Status `Scheduled` → "Démarrer" → PUT status `In Progress`
|
||||||
|
- Status `In Progress` → "Terminer" → PUT status `Completed`
|
||||||
|
- Status `Completed` → no button (dispatch has to reopen)
|
||||||
|
|
||||||
|
Both status values have legacy aliases (`assigned`, `in_progress`) because
|
||||||
|
the SSR page uses ERPNext's Title Case and older Python scripts emit
|
||||||
|
lowercase. The computed `statusLabel` handles both.
|
||||||
|
|
||||||
|
### 4c. `TechScanPage.vue` — standalone scanner
|
||||||
|
|
||||||
|
The top-level scanner route. Accepts any barcode, looks up by serial in
|
||||||
|
ERPNext, routes to `/j/device/:serial` on a hit or offers "Create
|
||||||
|
equipment" on a miss. Useful for techs auditing a van inventory or
|
||||||
|
confirming a serial before scheduled install time.
|
||||||
|
|
||||||
|
### 4d. `TechDevicePage.vue` — per-device detail
|
||||||
|
|
||||||
|
GET `Service Equipment/:serial` → shows brand, model, MAC, install date,
|
||||||
|
linked customer, linked address, recent diagnostic runs (via
|
||||||
|
`useSpeedTest` composable). "Retirer" button unlinks from current job
|
||||||
|
(PUT `linked_job: null`) but preserves the doc for audit.
|
||||||
|
|
||||||
|
### 4e. `TechDiagnosticPage.vue` — speed test + GenieACS probe
|
||||||
|
|
||||||
|
Runs an in-browser speed test via `useSpeedTest` and, if the device is
|
||||||
|
ACS-managed, fires a `GetParameterValues` against GenieACS through
|
||||||
|
`targo-hub /acs/probe`. Results render as a one-page PDF the tech can
|
||||||
|
SMS to the customer.
|
||||||
|
|
||||||
|
### 4f. `TechMorePage.vue` — settings, logout, version
|
||||||
|
|
||||||
|
Shows tech name, token expiry countdown, link to "Request new magic
|
||||||
|
link" (POSTs `/magic-link/refresh`), and a hard logout that wipes
|
||||||
|
localStorage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Equipment management (the UX that was re-ported 2026-04-22)
|
||||||
|
|
||||||
|
This section drove today's port from `apps/field/src/pages/JobDetailPage.vue`
|
||||||
|
→ `apps/ops/src/modules/tech/pages/TechJobDetailPage.vue`. The Ops SPA
|
||||||
|
stub had dropped it. It's back.
|
||||||
|
|
||||||
|
### 5a. The bottom-sheet trigger
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<q-btn flat dense label="Ajouter" icon="add" @click="addEquipmentMenu = true" />
|
||||||
|
|
||||||
|
<q-dialog v-model="addEquipmentMenu" position="bottom">
|
||||||
|
<q-list>
|
||||||
|
<q-item clickable v-close-popup @click="goToScanner">
|
||||||
|
<q-item-section avatar><q-icon name="qr_code_scanner" /></q-item-section>
|
||||||
|
<q-item-section>Scanner un code-barres / QR</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item clickable v-close-popup @click="searchEquipDialog = true">
|
||||||
|
<q-item-section avatar><q-icon name="search" /></q-item-section>
|
||||||
|
<q-item-section>Rechercher un équipement existant</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item clickable v-close-popup @click="createEquipDialog = true">
|
||||||
|
<q-item-section avatar><q-icon name="add_circle" /></q-item-section>
|
||||||
|
<q-item-section>Créer un nouvel équipement</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5b. Scan → link
|
||||||
|
|
||||||
|
`goToScanner()` `router.push({ name: 'tech-scan' })`, passing the current
|
||||||
|
`jobName` in query string. `TechScanPage` on a barcode hit calls
|
||||||
|
`linkEquipToJob(equipment)` which PUTs `linked_job: jobName` on the
|
||||||
|
`Service Equipment` row.
|
||||||
|
|
||||||
|
### 5c. Search → link
|
||||||
|
|
||||||
|
Debounced 400ms on `eqSearchText`. First query is by `serial_number`;
|
||||||
|
if that returns empty and the string looks like a MAC
|
||||||
|
(hex+separators), falls through to `mac_address`. Results render as
|
||||||
|
selectable rows; tap → `linkEquipToJob()`.
|
||||||
|
|
||||||
|
### 5d. Create → link (pre-filled from scan or blank)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const newEquip = ref({
|
||||||
|
serial_number: '', // autofocus
|
||||||
|
equipment_type: 'ONT', // select
|
||||||
|
brand: '', // Marque
|
||||||
|
model: '', // Modèle
|
||||||
|
mac_address: '', // MAC optionnel
|
||||||
|
})
|
||||||
|
|
||||||
|
const eqTypes = ['ONT', 'Modem', 'Routeur', 'Décodeur TV',
|
||||||
|
'VoIP', 'Amplificateur', 'Splitter', 'Câble', 'Autre']
|
||||||
|
```
|
||||||
|
|
||||||
|
`createAndLinkEquip()` POSTs a `Service Equipment` doc with these fields
|
||||||
|
plus `status: 'In Service'`, `linked_job: jobName`,
|
||||||
|
`service_location: job.service_location`. The doc name is auto-generated
|
||||||
|
(ERPNext auto-series `EQ-.####`).
|
||||||
|
|
||||||
|
### 5e. "Retirer" (unlink from job, preserve doc)
|
||||||
|
|
||||||
|
Button on each equipment row. PUT `linked_job: null` + optional
|
||||||
|
`status: 'Returned'`. The doc itself is never deleted — audit trail
|
||||||
|
matters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Hub API endpoints (all three surfaces call these)
|
||||||
|
|
||||||
|
Direct ERPNext calls via the tech's JWT (the Vue SPA wraps these in
|
||||||
|
`src/api/erp.js`):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/resource/Dispatch%20Job?filters=… → list today's jobs
|
||||||
|
GET /api/resource/Dispatch%20Job/:name → job detail
|
||||||
|
PUT /api/resource/Dispatch%20Job/:name → status, priority, etc.
|
||||||
|
GET /api/resource/Service%20Equipment?filters=… → list linked
|
||||||
|
GET /api/resource/Service%20Equipment/:serial → detail
|
||||||
|
POST /api/resource/Service%20Equipment → create
|
||||||
|
PUT /api/resource/Service%20Equipment/:serial → link/unlink
|
||||||
|
GET /api/resource/Service%20Location/:name → expand location
|
||||||
|
```
|
||||||
|
|
||||||
|
Hub-mediated calls (all `https://msg.gigafibre.ca/…`):
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /vision/barcodes → Gemini vision, {image} → {barcodes: []}
|
||||||
|
POST /vision/equipment → Gemini vision, {image} → {equipment: []}
|
||||||
|
POST /magic-link/refresh → reissue + SMS → {sent_to: "+1…"}
|
||||||
|
GET /magic-link/verify?token → check expiry → {tech_id, exp}
|
||||||
|
POST /acs/probe → GenieACS GetParamValues → {params: {}}
|
||||||
|
```
|
||||||
|
|
||||||
|
The legacy SSR surface at `/t/{token}/…` replicates a subset of these
|
||||||
|
(`/scan`, `/vision`, `/equip`, `/equip-remove`, `/status`, `/catalog`,
|
||||||
|
`/equip-list`) but as token-scoped paths rather than REST + JWT header.
|
||||||
|
When the SSR page is retired those paths will 410 Gone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. File inventory
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/ops/src/modules/tech/pages/
|
||||||
|
├── TechTasksPage.vue (today's jobs, grouped by status)
|
||||||
|
├── TechJobDetailPage.vue ← ported 2026-04-22 (equipment UX)
|
||||||
|
├── TechScanPage.vue (standalone scanner)
|
||||||
|
├── TechDevicePage.vue (per-device detail)
|
||||||
|
├── TechDiagnosticPage.vue (speed test + ACS probe)
|
||||||
|
└── TechMorePage.vue (settings, logout, token expiry)
|
||||||
|
|
||||||
|
apps/ops/src/layouts/
|
||||||
|
└── TechLayout.vue (bottom tab bar, mobile-optimized viewport)
|
||||||
|
|
||||||
|
apps/ops/src/composables/
|
||||||
|
└── useScanner.js (native <input> + Gemini, offline queue)
|
||||||
|
|
||||||
|
apps/ops/src/stores/
|
||||||
|
└── offline.js (IndexedDB queue, online-event flush)
|
||||||
|
|
||||||
|
apps/ops/src/api/
|
||||||
|
└── erp.js (getDoc, listDocs, createDoc, updateDoc wrappers)
|
||||||
|
|
||||||
|
apps/field/ ← transitional PWA, same layout, retiring
|
||||||
|
└── src/pages/JobDetailPage.vue (the 526-line reference for the port)
|
||||||
|
|
||||||
|
services/targo-hub/lib/
|
||||||
|
├── tech-mobile.js ← legacy SSR surface at /t/{token}
|
||||||
|
├── magic-link.js ← JWT mint/verify/refresh, shared w/ portal
|
||||||
|
└── vision.js ← /vision/barcodes + /vision/equipment
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Open questions / known gaps
|
||||||
|
|
||||||
|
- [ ] **Cutover**: flip `FIELD_APP_URL` (or `generateLink()`) to point at
|
||||||
|
`https://erp.gigafibre.ca/ops/#/j` and retire the SSR page. Needs a
|
||||||
|
regression sweep of the equipment bottom-sheet on iOS Safari 16 and
|
||||||
|
Android Chrome 120.
|
||||||
|
- [ ] **Offline create-equipment**: `createDoc` is not queued yet. If a
|
||||||
|
tech creates a device while offline the operation silently
|
||||||
|
`catch()`es. Needs a write queue analogous to `enqueueVisionScan`.
|
||||||
|
- [ ] **Dual-language strings**: UI is French-only. Roadmap item to
|
||||||
|
extract to `src/i18n/` once Ops desktop i18n lands.
|
||||||
|
- [ ] **Equipment type normalization**: `eqTypes` array is hard-coded.
|
||||||
|
ERPNext has an "Equipment Type" doctype — wire the dropdown to it
|
||||||
|
so new types don't require a frontend deploy.
|
||||||
|
- [ ] **GenieACS auto-probe**: currently the tech has to navigate to
|
||||||
|
`/j/diagnostic` manually. Could fire a background probe the moment
|
||||||
|
a device is scanned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Related docs
|
||||||
|
|
||||||
|
- [features/vision-ocr.md](vision-ocr.md) — the Gemini pipeline backing the scanner
|
||||||
|
- [features/dispatch.md](dispatch.md) — where `Dispatch Job` rows are created + assigned
|
||||||
|
- [features/customer-portal.md](customer-portal.md) — same magic-link pattern, 24h TTL, different token schema
|
||||||
|
- [features/cpe-management.md](cpe-management.md) — GenieACS probe called by `TechDiagnosticPage`
|
||||||
|
- [architecture/overview.md](../architecture/overview.md) — Traefik routes, Authentik wrapping, hub placement
|
||||||
|
- [architecture/module-interactions.md](../architecture/module-interactions.md) — cross-module read/write matrix
|
||||||
|
|
||||||
|
Back to [docs/features/README.md](README.md) · [docs/README.md](../README.md)
|
||||||
Loading…
Reference in New Issue
Block a user