fix(field/ops): restore live camera + multi-barcode scanning at /j/scan
The Apr 22 refactor (41d9b5f) collapsed the tech scanner to Gemini-only
photo capture, dropping the live camera viewport and client-side multi-
barcode detection. Techs lost the fast point-and-scan flow that handles
90% of routine installs.
Restored as a hybrid: html5-qrcode as the primary path (instant, offline,
standard QR/barcode), Gemini kept as a second-chance fallback for hard
labels (damaged stickers, text-only serials, unusual symbologies). Offline
queue + scanEquipmentLabel() preserved unchanged.
Three tabs, defaulting to live camera:
- Caméra — continuous html5-qrcode stream, detection auto-beeps
- Photo — native camera; full-image + 3-strip local scan, Gemini fallback
- Manuel — plain text input
Both apps/field and apps/ops updated in lockstep so nothing drifts while
apps/field is being folded into apps/ops/j.
Run `npm install` in apps/ops/ to pull in html5-qrcode before the next build.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
beb6ddc5e5
commit
90f5f2eaa0
|
|
@ -2,43 +2,61 @@ import { ref, watch } from 'vue'
|
|||
import { scanBarcodes } from 'src/api/ocr'
|
||||
import { useOfflineStore } from 'src/stores/offline'
|
||||
|
||||
const SCAN_TIMEOUT_MS = 8000
|
||||
const GEMINI_TIMEOUT_MS = 8000
|
||||
const MAX_CODES = 3
|
||||
|
||||
/**
|
||||
* Barcode scanner using device camera photo capture + Gemini Vision AI.
|
||||
* Hybrid barcode scanner for field techs.
|
||||
*
|
||||
* 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.
|
||||
* Three modes, all writing into the same `barcodes` array (max 3):
|
||||
*
|
||||
* Resilience: if Gemini doesn't answer within SCAN_TIMEOUT_MS (weak LTE),
|
||||
* the photo is queued in IndexedDB via the offline store and retried when
|
||||
* the signal comes back. The tech gets a "scan en attente" indicator and
|
||||
* can keep working; late results are delivered via onNewCode().
|
||||
* 1. `startLive(elId)` — continuous camera stream via html5-qrcode.
|
||||
* Instant, offline-capable, works for standard QR / Code128 / EAN.
|
||||
* This is the DEFAULT and fastest path.
|
||||
*
|
||||
* 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 {(code: string) => void} [options.onNewCode] — called for each
|
||||
* newly detected code, whether the scan was synchronous or delivered
|
||||
* later from the offline queue. Typically used to trigger lookup + notify.
|
||||
* @param {(code: string) => void} [options.onNewCode] — fires for each
|
||||
* newly added code whether it came from live, photo, queue, or manual.
|
||||
* Typically used to trigger lookup + toast.
|
||||
*/
|
||||
export function useScanner (options = {}) {
|
||||
const onNewCode = options.onNewCode || (() => {})
|
||||
const barcodes = ref([]) // Array of { value, region } — max 3
|
||||
const scanning = ref(false) // true while Gemini is processing
|
||||
const barcodes = ref([]) // [{ value, region }], max MAX_CODES
|
||||
const scanning = ref(false) // true while a photo/AI call is in-flight
|
||||
const live = ref(false) // true while the live camera is running
|
||||
const error = ref(null)
|
||||
const lastPhoto = ref(null) // data URI of last captured photo (thumbnail)
|
||||
const photos = ref([]) // all captured photo thumbnails
|
||||
const lastPhoto = ref(null) // thumbnail data URI
|
||||
const photos = ref([]) // [{ url, ts, codes, queued }]
|
||||
|
||||
const offline = useOfflineStore()
|
||||
let _html5 = null // Html5Qrcode instance for live mode
|
||||
|
||||
// Pick up any scans that completed while the page was unmounted (e.g. tech
|
||||
// queued a photo, locked phone, walked out of the basement, signal returns).
|
||||
// Pick up any Gemini scans that completed while the page was unmounted
|
||||
// (tech queued a photo, locked phone, walked out of the basement, signal
|
||||
// returned, hub replied, we stored the result in IndexedDB).
|
||||
for (const result of offline.scanResults) {
|
||||
mergeCodes(result.barcodes || [], 'queued')
|
||||
offline.consumeScanResult(result.id)
|
||||
}
|
||||
|
||||
// Watch for sync completions during the lifetime of this scanner.
|
||||
// Watch for sync completions during this scanner's lifetime.
|
||||
// Vue auto-disposes the watcher when the host component unmounts.
|
||||
watch(
|
||||
() => offline.scanResults.length,
|
||||
|
|
@ -51,10 +69,12 @@ export function useScanner (options = {}) {
|
|||
)
|
||||
|
||||
function addCode (code, region) {
|
||||
if (barcodes.value.length >= 3) return false
|
||||
if (barcodes.value.find(b => b.value === code)) return false
|
||||
barcodes.value.push({ value: code, region })
|
||||
onNewCode(code)
|
||||
const trimmed = String(code || '').trim()
|
||||
if (!trimmed) return false
|
||||
if (barcodes.value.length >= MAX_CODES) return false
|
||||
if (barcodes.value.find(b => b.value === trimmed)) return false
|
||||
barcodes.value.push({ value: trimmed, region })
|
||||
onNewCode(trimmed)
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -66,44 +86,109 @@ export function useScanner (options = {}) {
|
|||
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.
|
||||
* Resizes for AI, keeps thumbnail, sends to Gemini with an 8s timeout.
|
||||
* On timeout/failure, the photo is queued for background retry.
|
||||
*
|
||||
* Pass 1: html5-qrcode on the full image (cheap, offline).
|
||||
* 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) {
|
||||
if (!file) return []
|
||||
error.value = null
|
||||
scanning.value = true
|
||||
|
||||
let aiImage = null
|
||||
const found = []
|
||||
const photoIdx = photos.value.length
|
||||
let found = []
|
||||
let aiImage = null
|
||||
|
||||
try {
|
||||
// Create thumbnail for display (small)
|
||||
// Thumbnail for the UI
|
||||
const thumbUrl = await resizeImage(file, 400)
|
||||
lastPhoto.value = thumbUrl
|
||||
photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [], queued: false })
|
||||
|
||||
// Create optimized image for AI — keep high res for text readability
|
||||
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
|
||||
// --- Pass 1 + 2: local html5-qrcode on full image + strips ---
|
||||
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)
|
||||
try {
|
||||
const result = await scanBarcodesWithTimeout(aiImage, GEMINI_TIMEOUT_MS)
|
||||
found.push(...mergeCodes(result.barcodes || [], 'IA'))
|
||||
} catch (e) {
|
||||
if (isRetryable(e)) {
|
||||
await offline.enqueueVisionScan({ image: aiImage })
|
||||
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
|
||||
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (photos.value[photoIdx]) photos.value[photoIdx].codes = found
|
||||
if (found.length === 0 && !error.value) {
|
||||
error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point'
|
||||
}
|
||||
} 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'
|
||||
}
|
||||
error.value = e?.message || 'Erreur'
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
|
|
@ -111,6 +196,69 @@ export function useScanner (options = {}) {
|
|||
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) {
|
||||
return await Promise.race([
|
||||
scanBarcodes(image),
|
||||
|
|
@ -130,9 +278,11 @@ export function useScanner (options = {}) {
|
|||
|| e?.name === 'TypeError' // fetch throws TypeError on network error
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an image file to a max dimension, return as base64 data URI.
|
||||
*/
|
||||
// -------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Resize an image 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()
|
||||
|
|
@ -166,7 +316,15 @@ export function useScanner (options = {}) {
|
|||
}
|
||||
|
||||
return {
|
||||
barcodes, scanning, error, lastPhoto, photos,
|
||||
processPhoto, removeBarcode, clearBarcodes,
|
||||
// state
|
||||
barcodes, scanning, live, error, lastPhoto, photos,
|
||||
// live camera
|
||||
startLive, stopLive,
|
||||
// photo scan
|
||||
processPhoto,
|
||||
// shared
|
||||
addCode, removeBarcode, clearBarcodes,
|
||||
// constants
|
||||
MAX_CODES,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,55 +17,99 @@
|
|||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Camera capture button -->
|
||||
<div class="text-center">
|
||||
<!-- Mode tabs: Caméra / Photo IA / Manuel -->
|
||||
<q-tabs v-model="mode" dense no-caps active-color="primary" indicator-color="primary" align="justify"
|
||||
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
|
||||
color="primary" icon="photo_camera" label="Scanner"
|
||||
color="primary" icon="photo_camera" label="Prendre une photo"
|
||||
size="lg" rounded unelevated
|
||||
@click="takePhoto"
|
||||
:loading="scanner.scanning.value"
|
||||
class="q-px-xl"
|
||||
/>
|
||||
<input ref="cameraInput" type="file" accept="image/*" capture="environment" class="hidden" @change="onPhoto" />
|
||||
</div>
|
||||
<div class="text-caption text-grey q-mt-sm">
|
||||
Jusqu'à 3 codes par photo · IA activée si la lecture locale échoue
|
||||
</div>
|
||||
|
||||
<!-- Pending scan indicator (signal faible) -->
|
||||
<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 @click="offline.syncVisionQueue()">
|
||||
{{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }} en attente · toucher pour réessayer
|
||||
</q-chip>
|
||||
</div>
|
||||
<!-- Pending scan indicator (signal faible) -->
|
||||
<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
|
||||
@click="offline.syncVisionQueue()">
|
||||
{{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }}
|
||||
en attente · toucher pour réessayer
|
||||
</q-chip>
|
||||
</div>
|
||||
|
||||
<!-- Last captured photo (thumbnail) -->
|
||||
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
|
||||
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
|
||||
<div v-if="scanner.scanning.value" class="preview-overlay">
|
||||
<q-spinner-dots size="32px" color="white" />
|
||||
<div class="text-white text-caption q-mt-xs">Analyse Gemini...</div>
|
||||
<!-- Last captured photo (thumbnail) -->
|
||||
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
|
||||
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
|
||||
<div v-if="scanner.scanning.value" class="preview-overlay">
|
||||
<q-spinner-dots size="32px" color="white" />
|
||||
<div class="text-white text-caption q-mt-xs">Analyse...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo history (small thumbnails) -->
|
||||
<div v-if="scanner.photos.value.length > 1" class="q-mt-md">
|
||||
<div class="text-caption text-grey q-mb-xs">Photos capturées</div>
|
||||
<div class="row q-gutter-xs">
|
||||
<div v-for="(p, i) in scanner.photos.value" :key="i" class="photo-thumb" @click="viewPhoto(p)">
|
||||
<img :src="p.url" />
|
||||
<q-badge v-if="p.codes.length" color="green" floating :label="p.codes.length" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error / status -->
|
||||
<!-- ==== 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">
|
||||
{{ scanner.error.value }}
|
||||
</div>
|
||||
|
||||
<!-- 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 -->
|
||||
<!-- ==== Scanned barcodes (shared across all modes) ==== -->
|
||||
<div v-if="scanner.barcodes.value.length > 0" class="q-mt-md">
|
||||
<div class="text-subtitle2 q-mb-xs">Codes détectés ({{ scanner.barcodes.value.length }}/3)</div>
|
||||
<div class="row items-center q-mb-xs">
|
||||
<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-section class="q-py-sm row items-center no-wrap">
|
||||
<q-icon name="qr_code" class="q-mr-sm" color="primary" />
|
||||
<div class="col">
|
||||
<div class="text-subtitle2 mono">{{ bc.value }}</div>
|
||||
<div class="text-caption text-grey">{{ bc.region }}</div>
|
||||
</div>
|
||||
<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)" />
|
||||
|
|
@ -86,7 +130,7 @@
|
|||
<q-btn flat dense size="sm" color="orange" label="Lier à un service" icon="link"
|
||||
@click="openLinkDialog(bc.value, lookupResults[bc.value].equipment)" />
|
||||
</div>
|
||||
<div v-else class="text-caption text-green q-mt-xs">
|
||||
<div v-else-if="lookupResults[bc.value].equipment.service_location" class="text-caption text-green q-mt-xs">
|
||||
<q-icon name="check_circle" size="xs" class="q-mr-xs" />
|
||||
{{ lookupResults[bc.value].equipment.service_location }}
|
||||
</div>
|
||||
|
|
@ -102,17 +146,6 @@
|
|||
</q-card>
|
||||
</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) -->
|
||||
<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"
|
||||
|
|
@ -202,7 +235,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useScanner } from 'src/composables/useScanner'
|
||||
import { listDocs, createDoc, updateDoc } from 'src/api/erp'
|
||||
|
|
@ -218,6 +251,8 @@ 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 manualCode = ref('')
|
||||
const lookingUp = ref(null)
|
||||
|
|
@ -258,10 +293,30 @@ const hasUnlinked = computed(() =>
|
|||
})
|
||||
)
|
||||
|
||||
// --- Camera ---
|
||||
// --- Mode switch: stop the camera when leaving Live, pause auto-restart ---
|
||||
|
||||
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 () {
|
||||
// Reset the input so same file triggers change
|
||||
// Reset the input so the same file triggers change
|
||||
if (cameraInput.value) cameraInput.value.value = ''
|
||||
cameraInput.value?.click()
|
||||
}
|
||||
|
|
@ -285,15 +340,16 @@ function viewPhoto (photo) {
|
|||
function addManual () {
|
||||
const code = manualCode.value.trim()
|
||||
if (!code) return
|
||||
if (scanner.barcodes.value.length >= 3) {
|
||||
Notify.create({ type: 'warning', message: 'Maximum 3 codes' })
|
||||
if (scanner.barcodes.value.length >= scanner.MAX_CODES) {
|
||||
Notify.create({ type: 'warning', message: `Maximum ${scanner.MAX_CODES} codes` })
|
||||
return
|
||||
}
|
||||
if (!scanner.barcodes.value.find(b => b.value === code)) {
|
||||
scanner.barcodes.value.push({ value: code, region: 'manuel' })
|
||||
lookupDevice(code)
|
||||
if (scanner.addCode(code, 'manuel')) {
|
||||
manualCode.value = ''
|
||||
} else {
|
||||
Notify.create({ type: 'info', message: 'Code déjà ajouté' })
|
||||
manualCode.value = ''
|
||||
}
|
||||
manualCode.value = ''
|
||||
}
|
||||
|
||||
// --- Device lookup ---
|
||||
|
|
@ -496,6 +552,25 @@ async function linkDeviceToService () {
|
|||
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 {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
|
|
@ -532,4 +607,8 @@ async function linkDeviceToService () {
|
|||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"@twilio/voice-sdk": "^2.18.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"cytoscape": "^3.33.2",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
|
|
|
|||
|
|
@ -1,37 +1,45 @@
|
|||
/**
|
||||
* useScanner — camera-capture + Gemini Vision composable.
|
||||
* useScanner — hybrid camera + barcode composable for field techs.
|
||||
*
|
||||
* Two capture modes, one pipeline:
|
||||
* - processPhoto(file) → barcode/serial extraction (ScanPage, /j)
|
||||
* - scanEquipmentLabel(file) → structured ONT/ONU label (equipment
|
||||
* linking, ClientDetailPage photos)
|
||||
* Three capture modes, one shared `barcodes` array:
|
||||
*
|
||||
* Both resize the photo twice:
|
||||
* - 400px for the on-screen thumbnail
|
||||
* - 1600px @ q=0.92 for Gemini (text readability > filesize)
|
||||
* 1. Live camera (startLive / stopLive)
|
||||
* html5-qrcode continuous stream. INSTANT, OFFLINE, and the fastest
|
||||
* path for normal QR codes on modem stickers. This is the default on
|
||||
* the /j/scan page — techs point, scan, done.
|
||||
*
|
||||
* 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`.
|
||||
* 2. Photo scan (processPhoto)
|
||||
* Take a picture with the native camera, then:
|
||||
* Pass 1 — html5-qrcode on the full image (cheap, offline)
|
||||
* Pass 2 — split into 3 horizontal strips, scan each — catches up
|
||||
* to 3 stacked barcodes on a single sticker (SN + MAC +
|
||||
* GPON SN is a common layout)
|
||||
* 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.
|
||||
*
|
||||
* 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).
|
||||
* 3. Equipment label scan (scanEquipmentLabel)
|
||||
* Dedicated Gemini-only path for structured ONT/ONU label OCR.
|
||||
* Used from ClientDetailPage/equipment-linking flows where the caller
|
||||
* wants a sync answer ({brand, model, serial, mac, gpon_sn, ...}) —
|
||||
* not resilient, no queue, desktop/wifi only.
|
||||
*
|
||||
* 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/overview.md §"Legacy Retirement Plan" — field is being folded
|
||||
* into ops at /j and must not lose offline capability in the process.
|
||||
* History: this file originally lived in apps/field. In 2026-04-22 the
|
||||
* field scanner was briefly replaced with Gemini-only capture, losing
|
||||
* the live camera. This version restores live + multi-strip as the fast
|
||||
* primary path and keeps Gemini as a second-chance fallback.
|
||||
*
|
||||
* 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 {(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
|
||||
* newly added code. Fires for live scans, photo scans, queued retries,
|
||||
* and equipment-label scans. Typical use: trigger an ERPNext lookup
|
||||
* and Quasar notify.
|
||||
*/
|
||||
|
||||
|
|
@ -39,27 +47,25 @@ 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
|
||||
const GEMINI_TIMEOUT_MS = 8000
|
||||
const MAX_BARCODES = 5 // equipment labels have more identifiers than field cap of 3
|
||||
|
||||
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 scanning = ref(false) // true while a photo/AI call is in flight
|
||||
const live = ref(false) // true while the live camera is running
|
||||
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()
|
||||
let _html5 = null // Html5Qrcode live instance
|
||||
|
||||
// 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).
|
||||
// Pick up any Gemini scans that completed while the composable was unmounted
|
||||
// (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)
|
||||
|
|
@ -78,10 +84,12 @@ export function useScanner (options = {}) {
|
|||
)
|
||||
|
||||
function addCode (code, region) {
|
||||
const trimmed = String(code || '').trim()
|
||||
if (!trimmed) return false
|
||||
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)
|
||||
if (barcodes.value.find(b => b.value === trimmed)) return false
|
||||
barcodes.value.push({ value: trimmed, region })
|
||||
onNewCode(trimmed)
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -93,42 +101,105 @@ export function useScanner (options = {}) {
|
|||
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.
|
||||
* 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) {
|
||||
if (!file) return []
|
||||
error.value = null
|
||||
scanning.value = true
|
||||
|
||||
let aiImage = null
|
||||
const found = []
|
||||
const photoIdx = photos.value.length
|
||||
let found = []
|
||||
let aiImage = null
|
||||
|
||||
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
|
||||
// --- Pass 1 + 2: local html5-qrcode on full image + strips ---
|
||||
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)
|
||||
try {
|
||||
const result = await scanBarcodesWithTimeout(aiImage, GEMINI_TIMEOUT_MS)
|
||||
found.push(...mergeCodes(result.barcodes || [], 'IA'))
|
||||
} catch (e) {
|
||||
if (isRetryable(e)) {
|
||||
await offline.enqueueVisionScan({ image: aiImage })
|
||||
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
|
||||
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (photos.value[photoIdx]) photos.value[photoIdx].codes = found
|
||||
if (found.length === 0 && !error.value) {
|
||||
error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point'
|
||||
}
|
||||
} 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'
|
||||
}
|
||||
error.value = e?.message || 'Erreur'
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
|
|
@ -136,6 +207,72 @@ export function useScanner (options = {}) {
|
|||
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.
|
||||
*
|
||||
|
|
@ -175,7 +312,10 @@ export function useScanner (options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Race scanBarcodes against a timeout. Used only for barcode mode. */
|
||||
// -------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async function scanBarcodesWithTimeout (image, ms) {
|
||||
return await Promise.race([
|
||||
scanBarcodes(image),
|
||||
|
|
@ -186,7 +326,6 @@ export function useScanner (options = {}) {
|
|||
])
|
||||
}
|
||||
|
||||
/** Retryable = worth queueing in IndexedDB for later. */
|
||||
function isRetryable (e) {
|
||||
const msg = (e?.message || '').toLowerCase()
|
||||
return msg.includes('scantimeout')
|
||||
|
|
@ -196,7 +335,6 @@ export function useScanner (options = {}) {
|
|||
|| 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()
|
||||
|
|
@ -230,7 +368,15 @@ export function useScanner (options = {}) {
|
|||
}
|
||||
|
||||
return {
|
||||
barcodes, scanning, error, lastPhoto, photos,
|
||||
processPhoto, scanEquipmentLabel, removeBarcode, clearBarcodes,
|
||||
// state
|
||||
barcodes, scanning, live, error, lastPhoto, photos,
|
||||
// live camera
|
||||
startLive, stopLive,
|
||||
// photo scans
|
||||
processPhoto, scanEquipmentLabel,
|
||||
// shared
|
||||
addCode, removeBarcode, clearBarcodes,
|
||||
// constants
|
||||
MAX_BARCODES,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
<!--
|
||||
TechScanPage — camera-based equipment scanner for field techs at /j/scan.
|
||||
|
||||
What it does in one sentence: the tech points their phone at an ONT/router
|
||||
label, Gemini reads the serial, and we look it up in ERPNext, optionally
|
||||
auto-linking the equipment to the tech's current Dispatch Job.
|
||||
Three capture modes (useScanner docs in src/composables/useScanner.js):
|
||||
• Caméra (live) — default, html5-qrcode continuous stream, instant + offline
|
||||
• Photo — native camera, local multi-strip + Gemini fallback, offline-queued
|
||||
• Manuel — plain text input, for bent/unreadable stickers
|
||||
|
||||
ERPNext relationships touched (see docs/features/vision-ocr.md §10 for the full
|
||||
data-model diagram):
|
||||
ERPNext relationships touched (see docs/features/vision-ocr.md §10):
|
||||
|
||||
Dispatch Job ─► Customer ─► Service Location ◄── Service Equipment
|
||||
(serial_number, barcode,
|
||||
mac_address, status)
|
||||
|
||||
The tech arrives via `/j/scan?job=JOB-001&customer=CUST-123&location=LOC-456`
|
||||
— when the scanned serial matches an unlinked Service Equipment row, we
|
||||
— when a scanned serial matches an unlinked Service Equipment row, we
|
||||
auto-patch customer + service_location on that row so the device is
|
||||
provably tied to that install address before the tech leaves.
|
||||
|
||||
|
|
@ -21,11 +21,11 @@
|
|||
same Customer + Service Location, so the scan result flows through
|
||||
naturally for any downstream ticket view.
|
||||
|
||||
Ported from apps/field/src/pages/ScanPage.vue during the field→ops
|
||||
unification (see docs/architecture/overview.md §"Legacy Retirement Plan"). Adapted
|
||||
for the ops router:
|
||||
- device-detail route name: 'tech-device' (was 'device' in field)
|
||||
- same query-param contract from TechJobDetailPage.goScan()
|
||||
History: this page started as apps/field/src/pages/ScanPage.vue (live + multi-
|
||||
strip), was briefly reduced to Gemini-only (Apr 2026), and is now hybrid.
|
||||
Live camera is the default because pointing-and-scanning is 10× faster than
|
||||
take-photo-wait-for-AI on a normal QR code. Photo+Gemini is kept as a
|
||||
second chance for damaged or text-only labels.
|
||||
-->
|
||||
<template>
|
||||
<q-page padding class="scan-page">
|
||||
|
|
@ -46,55 +46,99 @@
|
|||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Camera capture button -->
|
||||
<div class="text-center">
|
||||
<!-- Mode tabs: Caméra / Photo / Manuel -->
|
||||
<q-tabs v-model="mode" dense no-caps active-color="primary" indicator-color="primary" align="justify"
|
||||
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
|
||||
color="primary" icon="photo_camera" label="Scanner"
|
||||
color="primary" icon="photo_camera" label="Prendre une photo"
|
||||
size="lg" rounded unelevated
|
||||
@click="takePhoto"
|
||||
:loading="scanner.scanning.value"
|
||||
class="q-px-xl"
|
||||
/>
|
||||
<input ref="cameraInput" type="file" accept="image/*" capture="environment" class="hidden" @change="onPhoto" />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Pending scan indicator (signal faible → queue offline) -->
|
||||
<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 @click="offline.syncVisionQueue()">
|
||||
{{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }} en attente · toucher pour réessayer
|
||||
</q-chip>
|
||||
</div>
|
||||
<!-- Pending scan indicator (signal faible → queue offline) -->
|
||||
<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
|
||||
@click="offline.syncVisionQueue()">
|
||||
{{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }}
|
||||
en attente · toucher pour réessayer
|
||||
</q-chip>
|
||||
</div>
|
||||
|
||||
<!-- Last captured photo (thumbnail) -->
|
||||
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
|
||||
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
|
||||
<div v-if="scanner.scanning.value" class="preview-overlay">
|
||||
<q-spinner-dots size="32px" color="white" />
|
||||
<div class="text-white text-caption q-mt-xs">Analyse Gemini...</div>
|
||||
<!-- Last captured photo (thumbnail) -->
|
||||
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
|
||||
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
|
||||
<div v-if="scanner.scanning.value" class="preview-overlay">
|
||||
<q-spinner-dots size="32px" color="white" />
|
||||
<div class="text-white text-caption q-mt-xs">Analyse...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo history (small thumbnails) -->
|
||||
<div v-if="scanner.photos.value.length > 1" class="q-mt-md">
|
||||
<div class="text-caption text-grey q-mb-xs">Photos capturées</div>
|
||||
<div class="row q-gutter-xs justify-center">
|
||||
<div v-for="(p, i) in scanner.photos.value" :key="i" class="photo-thumb" @click="viewPhoto(p)">
|
||||
<img :src="p.url" />
|
||||
<q-badge v-if="p.codes.length" color="green" floating :label="p.codes.length" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error / status -->
|
||||
<!-- ==== 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">
|
||||
{{ scanner.error.value }}
|
||||
</div>
|
||||
|
||||
<!-- 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) -->
|
||||
<!-- ==== Scanned barcodes (shared across all modes) ==== -->
|
||||
<div v-if="scanner.barcodes.value.length > 0" class="q-mt-md">
|
||||
<div class="text-subtitle2 q-mb-xs">Codes détectés ({{ scanner.barcodes.value.length }})</div>
|
||||
<div class="row items-center q-mb-xs">
|
||||
<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-section class="q-py-sm row items-center no-wrap">
|
||||
<q-icon name="qr_code" class="q-mr-sm" color="primary" />
|
||||
<div class="col">
|
||||
<div class="text-subtitle2 mono">{{ bc.value }}</div>
|
||||
<div class="text-caption text-grey">{{ bc.region }}</div>
|
||||
</div>
|
||||
<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)" />
|
||||
|
|
@ -131,17 +175,6 @@
|
|||
</q-card>
|
||||
</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) -->
|
||||
<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"
|
||||
|
|
@ -231,7 +264,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useScanner } from 'src/composables/useScanner'
|
||||
import { listDocs, createDoc, updateDoc } from 'src/api/erp'
|
||||
|
|
@ -241,8 +274,8 @@ import { Notify } from 'quasar'
|
|||
const route = useRoute()
|
||||
const offline = useOfflineStore()
|
||||
// Each new code triggers both a toast AND a silent ERPNext lookup. The
|
||||
// callback fires for synchronous scans AND for scans that complete later
|
||||
// from the offline vision queue — the tech gets notified either way.
|
||||
// callback fires for live scans, photo scans, queued retries, and manual
|
||||
// entries — the tech gets notified either way.
|
||||
const scanner = useScanner({
|
||||
onNewCode: (code) => {
|
||||
Notify.create({ type: 'positive', message: code, timeout: 2500, icon: 'qr_code', color: 'deep-purple' })
|
||||
|
|
@ -250,6 +283,8 @@ 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 manualCode = ref('')
|
||||
const lookingUp = ref(null)
|
||||
|
|
@ -295,7 +330,28 @@ const hasUnlinked = computed(() =>
|
|||
})
|
||||
)
|
||||
|
||||
// ─── Camera capture ──────────────────────────────────────────────────
|
||||
// ─── Mode switch ─────────────────────────────────────────────────────
|
||||
|
||||
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 () {
|
||||
// Reset so the same file re-triggers change when tech scans, undoes, scans
|
||||
|
|
@ -321,11 +377,16 @@ function viewPhoto (photo) {
|
|||
function addManual () {
|
||||
const code = manualCode.value.trim()
|
||||
if (!code) return
|
||||
if (!scanner.barcodes.value.find(b => b.value === code)) {
|
||||
scanner.barcodes.value.push({ value: code, region: 'manuel' })
|
||||
lookupDevice(code)
|
||||
if (scanner.barcodes.value.length >= scanner.MAX_BARCODES) {
|
||||
Notify.create({ type: 'warning', message: `Maximum ${scanner.MAX_BARCODES} codes` })
|
||||
return
|
||||
}
|
||||
if (scanner.addCode(code, 'manuel')) {
|
||||
manualCode.value = ''
|
||||
} else {
|
||||
Notify.create({ type: 'info', message: 'Code déjà ajouté' })
|
||||
manualCode.value = ''
|
||||
}
|
||||
manualCode.value = ''
|
||||
}
|
||||
|
||||
// ─── Device lookup: 3-tier fallback ──────────────────────────────────
|
||||
|
|
@ -537,6 +598,25 @@ async function linkDeviceToService () {
|
|||
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 {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
|
|
@ -575,6 +655,6 @@ async function linkDeviceToService () {
|
|||
}
|
||||
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user