gigafibre-fsm/apps/ops/src/composables/useScanner.js
louispaulb e91b362d36 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 8f11313 and drop
   html5-qrcode from both package.json + lockfiles. No JS barcode
   library, no camera stream, no polyfills.

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

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

Docs:

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

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

237 lines
8.2 KiB
JavaScript

/**
* useScanner — camera-capture + Gemini Vision composable.
*
* Two capture modes, one pipeline:
* - processPhoto(file) → barcode/serial extraction (ScanPage, /j)
* - scanEquipmentLabel(file) → structured ONT/ONU label (equipment
* linking, ClientDetailPage photos)
*
* Both resize the photo twice:
* - 400px for the on-screen thumbnail
* - 1600px @ q=0.92 for Gemini (text readability > filesize)
*
* Resilience (barcode mode only):
* If Gemini doesn't answer within SCAN_TIMEOUT_MS (weak LTE, basement,
* service cold-start), the photo is queued in IndexedDB via the offline
* store and retried in the background. The tech sees a "scan en attente"
* chip, keeps scanning the next equipment, and the late result is pushed
* back into `barcodes` via a reactive watcher on `offline.scanResults`.
*
* Equipment-label mode does NOT queue — it's typically invoked on a desktop
* or strong wifi (indoor install, office) where the extra complexity of
* background retry isn't worth it, and callers want a synchronous answer
* (to pre-fill an equipment form).
*
* Merged from apps/ops/src/composables/useScanner.js (which had the
* equipment-label branch) and apps/field/src/composables/useScanner.js
* (which had the resilient timeout + offline queue). See
* docs/ARCHITECTURE.md §"Legacy Retirement Plan" — field is being folded
* into ops at /j and must not lose offline capability in the process.
*
* @param {object} options
* @param {(code: string) => void} [options.onNewCode] — fires for each
* newly detected code, whether the scan was synchronous OR delivered
* later from the offline queue. Typical use: trigger an ERPNext lookup
* and Quasar notify.
*/
import { ref, watch } from 'vue'
import { scanBarcodes, scanEquipmentLabel as apiScanEquipmentLabel } from 'src/api/ocr'
import { useOfflineStore } from 'src/stores/offline'
const SCAN_TIMEOUT_MS = 8000
export function useScanner (options = {}) {
const onNewCode = options.onNewCode || (() => {})
const barcodes = ref([]) // { value, region }[] — max MAX_BARCODES
const scanning = ref(false) // true while a Gemini call is in flight
const error = ref(null)
const lastPhoto = ref(null) // data URI of last thumbnail (400px)
const photos = ref([]) // { url, ts, codes, queued }[] — full history
// Field's default cap was 3 (phone screen estate); ops historically
// allowed 5 (equipment labels have more identifiers). Keep 5 here
// since equipment-label mode is an ops-only feature.
const MAX_BARCODES = 5
const offline = useOfflineStore()
// 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) {
mergeCodes(result.barcodes || [], 'queued')
offline.consumeScanResult(result.id)
}
// Watch for sync completions during the lifetime of this scanner.
// Vue auto-disposes the watcher when the host component unmounts.
watch(
() => offline.scanResults.length,
() => {
for (const result of [...offline.scanResults]) {
mergeCodes(result.barcodes || [], 'queued')
offline.consumeScanResult(result.id)
}
}
)
function addCode (code, region) {
if (barcodes.value.length >= MAX_BARCODES) return false
if (barcodes.value.find(b => b.value === code)) return false
barcodes.value.push({ value: code, region })
onNewCode(code)
return true
}
function mergeCodes (codes, region) {
const added = []
for (const code of codes) {
if (addCode(code, region)) added.push(code)
}
return added
}
/**
* Process a photo for generic barcode/serial extraction.
* Resilient: on timeout/network error the photo is queued for retry.
*/
async function processPhoto (file) {
if (!file) return []
error.value = null
scanning.value = true
let aiImage = null
const photoIdx = photos.value.length
let found = []
try {
const thumbUrl = await resizeImage(file, 400)
lastPhoto.value = thumbUrl
photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [], queued: false })
// Keep high-res for text readability (small serial fonts).
aiImage = await resizeImage(file, 1600, 0.92)
const result = await scanBarcodesWithTimeout(aiImage, SCAN_TIMEOUT_MS)
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) {
if (aiImage && isRetryable(e)) {
await offline.enqueueVisionScan({ image: aiImage })
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
} else {
error.value = e.message || 'Erreur'
}
} finally {
scanning.value = false
}
return found
}
/**
* Process a photo for structured equipment-label extraction.
*
* Returns the Gemini response directly:
* { brand, model, serial_number, mac_address, gpon_sn, hw_version,
* equipment_type, barcodes: string[] }
*
* Side-effect: pushes `serial_number` + any `barcodes` into the same
* `barcodes` ref as processPhoto(), so a UI that uses both modes shares
* one list.
*
* Intentionally NOT resilient (no timeout, no queue) — equipment
* linking is a desktop/wifi flow, and callers want a sync answer.
*/
async function scanEquipmentLabel (file) {
if (!file) return null
error.value = null
scanning.value = true
try {
const thumbUrl = await resizeImage(file, 400)
lastPhoto.value = thumbUrl
const aiImage = await resizeImage(file, 1600, 0.92)
const data = await apiScanEquipmentLabel(aiImage)
if (data?.barcodes?.length) mergeCodes(data.barcodes, 'equipment')
if (data?.serial_number) addCode(data.serial_number, 'equipment')
if (!data?.serial_number && !data?.barcodes?.length) {
error.value = 'Aucun identifiant détecté — rapprochez-vous ou améliorez la mise au point'
}
return data
} catch (e) {
error.value = e.message || 'Erreur'
return null
} finally {
scanning.value = false
}
}
/** Race scanBarcodes against a timeout. Used only for barcode mode. */
async function scanBarcodesWithTimeout (image, ms) {
return await Promise.race([
scanBarcodes(image),
new Promise((_, reject) => setTimeout(
() => reject(new Error('ScanTimeout')),
ms,
)),
])
}
/** Retryable = worth queueing in IndexedDB for later. */
function isRetryable (e) {
const msg = (e?.message || '').toLowerCase()
return msg.includes('scantimeout')
|| msg.includes('failed to fetch')
|| msg.includes('networkerror')
|| msg.includes('load failed')
|| 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) {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
let { width, height } = img
if (width > maxDim || height > maxDim) {
const ratio = Math.min(maxDim / width, maxDim / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
canvas.getContext('2d').drawImage(img, 0, 0, width, height)
resolve(canvas.toDataURL('image/jpeg', quality))
}
img.onerror = reject
img.src = URL.createObjectURL(file)
})
}
function removeBarcode (value) {
barcodes.value = barcodes.value.filter(b => b.value !== value)
}
function clearBarcodes () {
barcodes.value = []
error.value = null
lastPhoto.value = null
photos.value = []
}
return {
barcodes, scanning, error, lastPhoto, photos,
processPhoto, scanEquipmentLabel, removeBarcode, clearBarcodes,
}
}