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 { scanBarcodes } from 'src/api/ocr'
|
||||||
import { useOfflineStore } from 'src/stores/offline'
|
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
|
* Three modes, all writing into the same `barcodes` array (max 3):
|
||||||
* the native camera app — this gives proper autofocus, tap-to-focus,
|
|
||||||
* and high-res photos. Then send to Gemini Vision for barcode extraction.
|
|
||||||
*
|
*
|
||||||
* Resilience: if Gemini doesn't answer within SCAN_TIMEOUT_MS (weak LTE),
|
* 1. `startLive(elId)` — continuous camera stream via html5-qrcode.
|
||||||
* the photo is queued in IndexedDB via the offline store and retried when
|
* Instant, offline-capable, works for standard QR / Code128 / EAN.
|
||||||
* the signal comes back. The tech gets a "scan en attente" indicator and
|
* This is the DEFAULT and fastest path.
|
||||||
* 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] — called for each
|
* @param {(code: string) => void} [options.onNewCode] — fires for each
|
||||||
* newly detected code, whether the scan was synchronous or delivered
|
* newly added code whether it came from live, photo, queue, or manual.
|
||||||
* later from the offline queue. Typically used to trigger lookup + notify.
|
* Typically used to trigger lookup + toast.
|
||||||
*/
|
*/
|
||||||
export function useScanner (options = {}) {
|
export function useScanner (options = {}) {
|
||||||
const onNewCode = options.onNewCode || (() => {})
|
const onNewCode = options.onNewCode || (() => {})
|
||||||
const barcodes = ref([]) // Array of { value, region } — max 3
|
const barcodes = ref([]) // [{ value, region }], max MAX_CODES
|
||||||
const scanning = ref(false) // true while Gemini is processing
|
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 error = ref(null)
|
||||||
const lastPhoto = ref(null) // data URI of last captured photo (thumbnail)
|
const lastPhoto = ref(null) // thumbnail data URI
|
||||||
const photos = ref([]) // all captured photo thumbnails
|
const photos = ref([]) // [{ url, ts, codes, queued }]
|
||||||
|
|
||||||
const offline = useOfflineStore()
|
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
|
// Pick up any Gemini scans that completed while the page was unmounted
|
||||||
// queued a photo, locked phone, walked out of the basement, signal returns).
|
// (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) {
|
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 the lifetime of this scanner.
|
// Watch for sync completions during this scanner's lifetime.
|
||||||
// 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,
|
||||||
|
|
@ -51,10 +69,12 @@ export function useScanner (options = {}) {
|
||||||
)
|
)
|
||||||
|
|
||||||
function addCode (code, region) {
|
function addCode (code, region) {
|
||||||
if (barcodes.value.length >= 3) return false
|
const trimmed = String(code || '').trim()
|
||||||
if (barcodes.value.find(b => b.value === code)) return false
|
if (!trimmed) return false
|
||||||
barcodes.value.push({ value: code, region })
|
if (barcodes.value.length >= MAX_CODES) return false
|
||||||
onNewCode(code)
|
if (barcodes.value.find(b => b.value === trimmed)) return false
|
||||||
|
barcodes.value.push({ value: trimmed, region })
|
||||||
|
onNewCode(trimmed)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,44 +86,109 @@ 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.
|
*
|
||||||
* 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) {
|
async function processPhoto (file) {
|
||||||
if (!file) return []
|
if (!file) return []
|
||||||
error.value = null
|
error.value = null
|
||||||
scanning.value = true
|
scanning.value = true
|
||||||
|
|
||||||
let aiImage = null
|
const found = []
|
||||||
const photoIdx = photos.value.length
|
const photoIdx = photos.value.length
|
||||||
let found = []
|
let aiImage = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create thumbnail for display (small)
|
// Thumbnail for the UI
|
||||||
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 })
|
||||||
|
|
||||||
// Create optimized image for AI — keep high res for text readability
|
// --- Pass 1 + 2: local html5-qrcode on full image + strips ---
|
||||||
aiImage = await resizeImage(file, 1600, 0.92)
|
const localCodes = await scanPhotoLocally(file)
|
||||||
|
found.push(...mergeCodes(localCodes, 'photo'))
|
||||||
const result = await scanBarcodesWithTimeout(aiImage, SCAN_TIMEOUT_MS)
|
|
||||||
found = mergeCodes(result.barcodes || [], 'photo')
|
|
||||||
photos.value[photoIdx].codes = found
|
|
||||||
|
|
||||||
|
// --- Pass 3: Gemini fallback if local scan came up empty ---
|
||||||
if (found.length === 0) {
|
if (found.length === 0) {
|
||||||
|
aiImage = await resizeImage(file, 1600, 0.92)
|
||||||
|
try {
|
||||||
|
const result = await scanBarcodesWithTimeout(aiImage, GEMINI_TIMEOUT_MS)
|
||||||
|
found.push(...mergeCodes(result.barcodes || [], 'IA'))
|
||||||
|
} catch (e) {
|
||||||
|
if (isRetryable(e)) {
|
||||||
|
await offline.enqueueVisionScan({ image: aiImage })
|
||||||
|
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
|
||||||
|
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photos.value[photoIdx]) photos.value[photoIdx].codes = found
|
||||||
|
if (found.length === 0 && !error.value) {
|
||||||
error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point'
|
error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point'
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (aiImage && isRetryable(e)) {
|
error.value = e?.message || 'Erreur'
|
||||||
await offline.enqueueVisionScan({ image: aiImage })
|
|
||||||
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
|
|
||||||
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
|
|
||||||
} else {
|
|
||||||
error.value = e.message || 'Erreur'
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
scanning.value = false
|
scanning.value = false
|
||||||
}
|
}
|
||||||
|
|
@ -111,6 +196,69 @@ 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),
|
||||||
|
|
@ -130,9 +278,11 @@ export function useScanner (options = {}) {
|
||||||
|| e?.name === 'TypeError' // fetch throws TypeError on network error
|
|| 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) {
|
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()
|
||||||
|
|
@ -166,7 +316,15 @@ export function useScanner (options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
barcodes, scanning, error, lastPhoto, photos,
|
// state
|
||||||
processPhoto, removeBarcode, clearBarcodes,
|
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-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
<!-- Camera capture button -->
|
<!-- Mode tabs: Caméra / Photo IA / Manuel -->
|
||||||
<div class="text-center">
|
<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
|
<q-btn
|
||||||
color="primary" icon="photo_camera" label="Scanner"
|
color="primary" icon="photo_camera" label="Prendre une photo"
|
||||||
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>
|
<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) -->
|
<!-- 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 @click="offline.syncVisionQueue()">
|
<q-chip icon="wifi_tethering_off" color="orange" text-color="white" dense clickable
|
||||||
{{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }} en attente · toucher pour réessayer
|
@click="offline.syncVisionQueue()">
|
||||||
</q-chip>
|
{{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }}
|
||||||
</div>
|
en attente · toucher pour réessayer
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Last captured photo (thumbnail) -->
|
<!-- Last captured photo (thumbnail) -->
|
||||||
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
|
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
|
||||||
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
|
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
|
||||||
<div v-if="scanner.scanning.value" class="preview-overlay">
|
<div v-if="scanner.scanning.value" class="preview-overlay">
|
||||||
<q-spinner-dots size="32px" color="white" />
|
<q-spinner-dots size="32px" color="white" />
|
||||||
<div class="text-white text-caption q-mt-xs">Analyse Gemini...</div>
|
<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>
|
||||||
</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">
|
<div v-if="scanner.error.value" class="text-caption text-center q-mt-sm text-negative">
|
||||||
{{ scanner.error.value }}
|
{{ scanner.error.value }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Manual entry -->
|
<!-- ==== Scanned barcodes (shared across all modes) ==== -->
|
||||||
<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="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 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)" />
|
||||||
|
|
@ -86,7 +130,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 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" />
|
<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>
|
||||||
|
|
@ -102,17 +146,6 @@
|
||||||
</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"
|
||||||
|
|
@ -202,7 +235,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onBeforeUnmount, nextTick } 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'
|
||||||
|
|
@ -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 cameraInput = ref(null)
|
||||||
const manualCode = ref('')
|
const manualCode = ref('')
|
||||||
const lookingUp = ref(null)
|
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 () {
|
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 = ''
|
if (cameraInput.value) cameraInput.value.value = ''
|
||||||
cameraInput.value?.click()
|
cameraInput.value?.click()
|
||||||
}
|
}
|
||||||
|
|
@ -285,15 +340,16 @@ 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 >= 3) {
|
if (scanner.barcodes.value.length >= scanner.MAX_CODES) {
|
||||||
Notify.create({ type: 'warning', message: 'Maximum 3 codes' })
|
Notify.create({ type: 'warning', message: `Maximum ${scanner.MAX_CODES} codes` })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!scanner.barcodes.value.find(b => b.value === code)) {
|
if (scanner.addCode(code, 'manuel')) {
|
||||||
scanner.barcodes.value.push({ value: code, region: 'manuel' })
|
manualCode.value = ''
|
||||||
lookupDevice(code)
|
} else {
|
||||||
|
Notify.create({ type: 'info', message: 'Code déjà ajouté' })
|
||||||
|
manualCode.value = ''
|
||||||
}
|
}
|
||||||
manualCode.value = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Device lookup ---
|
// --- Device lookup ---
|
||||||
|
|
@ -496,6 +552,25 @@ 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;
|
||||||
|
|
@ -532,4 +607,8 @@ async function linkDeviceToService () {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"@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,37 +1,45 @@
|
||||||
/**
|
/**
|
||||||
* useScanner — camera-capture + Gemini Vision composable.
|
* useScanner — hybrid camera + barcode composable for field techs.
|
||||||
*
|
*
|
||||||
* Two capture modes, one pipeline:
|
* Three capture modes, one shared `barcodes` array:
|
||||||
* - processPhoto(file) → barcode/serial extraction (ScanPage, /j)
|
|
||||||
* - scanEquipmentLabel(file) → structured ONT/ONU label (equipment
|
|
||||||
* linking, ClientDetailPage photos)
|
|
||||||
*
|
*
|
||||||
* Both resize the photo twice:
|
* 1. Live camera (startLive / stopLive)
|
||||||
* - 400px for the on-screen thumbnail
|
* html5-qrcode continuous stream. INSTANT, OFFLINE, and the fastest
|
||||||
* - 1600px @ q=0.92 for Gemini (text readability > filesize)
|
* 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):
|
* 2. Photo scan (processPhoto)
|
||||||
* If Gemini doesn't answer within SCAN_TIMEOUT_MS (weak LTE, basement,
|
* Take a picture with the native camera, then:
|
||||||
* service cold-start), the photo is queued in IndexedDB via the offline
|
* Pass 1 — html5-qrcode on the full image (cheap, offline)
|
||||||
* store and retried in the background. The tech sees a "scan en attente"
|
* Pass 2 — split into 3 horizontal strips, scan each — catches up
|
||||||
* chip, keeps scanning the next equipment, and the late result is pushed
|
* to 3 stacked barcodes on a single sticker (SN + MAC +
|
||||||
* back into `barcodes` via a reactive watcher on `offline.scanResults`.
|
* 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
|
* 3. Equipment label scan (scanEquipmentLabel)
|
||||||
* or strong wifi (indoor install, office) where the extra complexity of
|
* Dedicated Gemini-only path for structured ONT/ONU label OCR.
|
||||||
* background retry isn't worth it, and callers want a synchronous answer
|
* Used from ClientDetailPage/equipment-linking flows where the caller
|
||||||
* (to pre-fill an equipment form).
|
* 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
|
* History: this file originally lived in apps/field. In 2026-04-22 the
|
||||||
* equipment-label branch) and apps/field/src/composables/useScanner.js
|
* field scanner was briefly replaced with Gemini-only capture, losing
|
||||||
* (which had the resilient timeout + offline queue). See
|
* the live camera. This version restores live + multi-strip as the fast
|
||||||
* docs/architecture/overview.md §"Legacy Retirement Plan" — field is being folded
|
* primary path and keeps Gemini as a second-chance fallback.
|
||||||
* 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 detected code, whether the scan was synchronous OR delivered
|
* newly added code. Fires for live scans, photo scans, queued retries,
|
||||||
* later from the offline queue. Typical use: trigger an ERPNext lookup
|
* and equipment-label scans. Typical use: trigger an ERPNext lookup
|
||||||
* and Quasar notify.
|
* and Quasar notify.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -39,27 +47,25 @@ 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 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 = {}) {
|
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 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 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
|
||||||
|
|
||||||
// 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()
|
const offline = useOfflineStore()
|
||||||
|
let _html5 = null // Html5Qrcode live instance
|
||||||
|
|
||||||
// Pick up any scans that completed while the composable was unmounted
|
// Pick up any Gemini scans that completed while the composable was unmounted
|
||||||
// (e.g. tech queued a photo in the basement, phone locked, signal
|
// (tech queued a photo in the basement, phone locked, signal returned while
|
||||||
// returned while the page was gone, now they reopen ScanPage).
|
// 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)
|
||||||
|
|
@ -78,10 +84,12 @@ 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 === code)) return false
|
if (barcodes.value.find(b => b.value === trimmed)) return false
|
||||||
barcodes.value.push({ value: code, region })
|
barcodes.value.push({ value: trimmed, region })
|
||||||
onNewCode(code)
|
onNewCode(trimmed)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,42 +101,105 @@ 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
|
||||||
|
|
||||||
let aiImage = null
|
const found = []
|
||||||
const photoIdx = photos.value.length
|
const photoIdx = photos.value.length
|
||||||
let found = []
|
let aiImage = null
|
||||||
|
|
||||||
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 })
|
||||||
|
|
||||||
// Keep high-res for text readability (small serial fonts).
|
// --- Pass 1 + 2: local html5-qrcode on full image + strips ---
|
||||||
aiImage = await resizeImage(file, 1600, 0.92)
|
const localCodes = await scanPhotoLocally(file)
|
||||||
|
found.push(...mergeCodes(localCodes, 'photo'))
|
||||||
const result = await scanBarcodesWithTimeout(aiImage, SCAN_TIMEOUT_MS)
|
|
||||||
found = mergeCodes(result.barcodes || [], 'photo')
|
|
||||||
photos.value[photoIdx].codes = found
|
|
||||||
|
|
||||||
|
// --- Pass 3: Gemini fallback if local scan came up empty ---
|
||||||
if (found.length === 0) {
|
if (found.length === 0) {
|
||||||
|
aiImage = await resizeImage(file, 1600, 0.92)
|
||||||
|
try {
|
||||||
|
const result = await scanBarcodesWithTimeout(aiImage, GEMINI_TIMEOUT_MS)
|
||||||
|
found.push(...mergeCodes(result.barcodes || [], 'IA'))
|
||||||
|
} catch (e) {
|
||||||
|
if (isRetryable(e)) {
|
||||||
|
await offline.enqueueVisionScan({ image: aiImage })
|
||||||
|
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
|
||||||
|
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photos.value[photoIdx]) photos.value[photoIdx].codes = found
|
||||||
|
if (found.length === 0 && !error.value) {
|
||||||
error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point'
|
error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point'
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (aiImage && isRetryable(e)) {
|
error.value = e?.message || 'Erreur'
|
||||||
await offline.enqueueVisionScan({ image: aiImage })
|
|
||||||
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
|
|
||||||
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
|
|
||||||
} else {
|
|
||||||
error.value = e.message || 'Erreur'
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
scanning.value = false
|
scanning.value = false
|
||||||
}
|
}
|
||||||
|
|
@ -136,6 +207,72 @@ 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.
|
||||||
*
|
*
|
||||||
|
|
@ -175,7 +312,10 @@ 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),
|
||||||
|
|
@ -186,7 +326,6 @@ 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')
|
||||||
|
|
@ -196,7 +335,6 @@ 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()
|
||||||
|
|
@ -230,7 +368,15 @@ export function useScanner (options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
barcodes, scanning, error, lastPhoto, photos,
|
// state
|
||||||
processPhoto, scanEquipmentLabel, removeBarcode, clearBarcodes,
|
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.
|
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
|
Three capture modes (useScanner docs in src/composables/useScanner.js):
|
||||||
label, Gemini reads the serial, and we look it up in ERPNext, optionally
|
• Caméra (live) — default, html5-qrcode continuous stream, instant + offline
|
||||||
auto-linking the equipment to the tech's current Dispatch Job.
|
• 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
|
ERPNext relationships touched (see docs/features/vision-ocr.md §10):
|
||||||
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 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
|
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.
|
||||||
|
|
||||||
Ported from apps/field/src/pages/ScanPage.vue during the field→ops
|
History: this page started as apps/field/src/pages/ScanPage.vue (live + multi-
|
||||||
unification (see docs/architecture/overview.md §"Legacy Retirement Plan"). Adapted
|
strip), was briefly reduced to Gemini-only (Apr 2026), and is now hybrid.
|
||||||
for the ops router:
|
Live camera is the default because pointing-and-scanning is 10× faster than
|
||||||
- device-detail route name: 'tech-device' (was 'device' in field)
|
take-photo-wait-for-AI on a normal QR code. Photo+Gemini is kept as a
|
||||||
- same query-param contract from TechJobDetailPage.goScan()
|
second chance for damaged or text-only labels.
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<q-page padding class="scan-page">
|
<q-page padding class="scan-page">
|
||||||
|
|
@ -46,55 +46,99 @@
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
<!-- Camera capture button -->
|
<!-- Mode tabs: Caméra / Photo / Manuel -->
|
||||||
<div class="text-center">
|
<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
|
<q-btn
|
||||||
color="primary" icon="photo_camera" label="Scanner"
|
color="primary" icon="photo_camera" label="Prendre une photo"
|
||||||
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>
|
<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) -->
|
<!-- 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 @click="offline.syncVisionQueue()">
|
<q-chip icon="wifi_tethering_off" color="orange" text-color="white" dense clickable
|
||||||
{{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }} en attente · toucher pour réessayer
|
@click="offline.syncVisionQueue()">
|
||||||
</q-chip>
|
{{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }}
|
||||||
</div>
|
en attente · toucher pour réessayer
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Last captured photo (thumbnail) -->
|
<!-- Last captured photo (thumbnail) -->
|
||||||
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
|
<div v-if="scanner.lastPhoto.value" class="photo-preview q-mt-md">
|
||||||
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
|
<img :src="scanner.lastPhoto.value" class="preview-img" @click="showFullPhoto = true" />
|
||||||
<div v-if="scanner.scanning.value" class="preview-overlay">
|
<div v-if="scanner.scanning.value" class="preview-overlay">
|
||||||
<q-spinner-dots size="32px" color="white" />
|
<q-spinner-dots size="32px" color="white" />
|
||||||
<div class="text-white text-caption q-mt-xs">Analyse Gemini...</div>
|
<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>
|
||||||
</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">
|
<div v-if="scanner.error.value" class="text-caption text-center q-mt-sm text-negative">
|
||||||
{{ scanner.error.value }}
|
{{ scanner.error.value }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Manual entry fallback -->
|
<!-- ==== Scanned barcodes (shared across all modes) ==== -->
|
||||||
<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="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 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)" />
|
||||||
|
|
@ -131,17 +175,6 @@
|
||||||
</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"
|
||||||
|
|
@ -231,7 +264,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, nextTick, onBeforeUnmount } 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'
|
||||||
|
|
@ -241,8 +274,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 synchronous scans AND for scans that complete later
|
// callback fires for live scans, photo scans, queued retries, and manual
|
||||||
// from the offline vision queue — the tech gets notified either way.
|
// entries — 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' })
|
||||||
|
|
@ -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 cameraInput = ref(null)
|
||||||
const manualCode = ref('')
|
const manualCode = ref('')
|
||||||
const lookingUp = ref(null)
|
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 () {
|
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
|
||||||
|
|
@ -321,11 +377,16 @@ 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.find(b => b.value === code)) {
|
if (scanner.barcodes.value.length >= scanner.MAX_BARCODES) {
|
||||||
scanner.barcodes.value.push({ value: code, region: 'manuel' })
|
Notify.create({ type: 'warning', message: `Maximum ${scanner.MAX_BARCODES} codes` })
|
||||||
lookupDevice(code)
|
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 ──────────────────────────────────
|
// ─── Device lookup: 3-tier fallback ──────────────────────────────────
|
||||||
|
|
@ -537,6 +598,25 @@ 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;
|
||||||
|
|
@ -575,6 +655,6 @@ async function linkDeviceToService () {
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user