Major additions accumulated over 9 days — single commit per request. Flow editor (new): - Generic visual editor for step trees, usable by project wizard + agent flows - PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain - Drag-and-drop reorder via vuedraggable with scope isolation per peer group - Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved) - Variable picker with per-applies_to catalog (Customer / Quotation / Service Contract / Issue / Subscription), insert + copy-clipboard modes - trigger_condition helper with domain-specific JSONLogic examples - Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern - Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js - ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates - depends_on chips resolve to step labels instead of opaque "s4" ids QR/OCR scanner (field app): - Camera capture → Gemini Vision via targo-hub with 8s timeout - IndexedDB offline queue retries photos when signal returns - Watcher merges late-arriving scan results into the live UI Dispatch: - Planning mode (draft → publish) with offer pool for unassigned jobs - Shared presets, recurrence selector, suggested-slots dialog - PublishScheduleModal, unassign confirmation Ops app: - ClientDetailPage composables extraction (useClientData, useDeviceStatus, useWifiDiagnostic, useModemDiagnostic) - Project wizard: shared detail sections, wizard catalog/publish composables - Address pricing composable + pricing-mock data - Settings redesign hosting flow templates Targo-hub: - Contract acceptance (JWT residential + DocuSeal commercial tracks) - Referral system - Modem-bridge diagnostic normalizer - Device extractors consolidated Migration scripts: - Invoice/quote print format setup, Jinja rendering - Additional import + fix scripts (reversals, dates, customers, payments) Docs: - Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS, FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT, APP_DESIGN_GUIDELINES - Archived legacy wizard PHP for reference - STATUS snapshots for 2026-04-18/19 Cleanup: - Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*) - .gitignore now covers invoice preview output + nested .DS_Store Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
173 lines
5.6 KiB
JavaScript
173 lines
5.6 KiB
JavaScript
import { ref, watch } from 'vue'
|
|
import { scanBarcodes } from 'src/api/ocr'
|
|
import { useOfflineStore } from 'src/stores/offline'
|
|
|
|
const SCAN_TIMEOUT_MS = 8000
|
|
|
|
/**
|
|
* Barcode scanner using device camera photo capture + Gemini Vision AI.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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().
|
|
*
|
|
* @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.
|
|
*/
|
|
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 error = ref(null)
|
|
const lastPhoto = ref(null) // data URI of last captured photo (thumbnail)
|
|
const photos = ref([]) // all captured photo thumbnails
|
|
|
|
const offline = useOfflineStore()
|
|
|
|
// 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).
|
|
for (const result of offline.scanResults) {
|
|
mergeCodes(result.barcodes || [], 'queued')
|
|
offline.consumeScanResult(result.id)
|
|
}
|
|
|
|
// Watch for sync completions during the lifetime of this scanner.
|
|
// Vue auto-disposes the watcher when the host component unmounts.
|
|
watch(
|
|
() => offline.scanResults.length,
|
|
() => {
|
|
for (const result of [...offline.scanResults]) {
|
|
mergeCodes(result.barcodes || [], 'queued')
|
|
offline.consumeScanResult(result.id)
|
|
}
|
|
}
|
|
)
|
|
|
|
function addCode (code, region) {
|
|
if (barcodes.value.length >= 3) return false
|
|
if (barcodes.value.find(b => b.value === code)) return false
|
|
barcodes.value.push({ value: code, region })
|
|
onNewCode(code)
|
|
return true
|
|
}
|
|
|
|
function mergeCodes (codes, region) {
|
|
const added = []
|
|
for (const code of codes) {
|
|
if (addCode(code, region)) added.push(code)
|
|
}
|
|
return added
|
|
}
|
|
|
|
/**
|
|
* Process a photo 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.
|
|
*/
|
|
async function processPhoto (file) {
|
|
if (!file) return []
|
|
error.value = null
|
|
scanning.value = true
|
|
|
|
let aiImage = null
|
|
const photoIdx = photos.value.length
|
|
let found = []
|
|
|
|
try {
|
|
// Create thumbnail for display (small)
|
|
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
|
|
|
|
if (found.length === 0) {
|
|
error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point'
|
|
}
|
|
} catch (e) {
|
|
if (aiImage && isRetryable(e)) {
|
|
await offline.enqueueVisionScan({ image: aiImage })
|
|
if (photos.value[photoIdx]) photos.value[photoIdx].queued = true
|
|
error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.'
|
|
} else {
|
|
error.value = e.message || 'Erreur'
|
|
}
|
|
} finally {
|
|
scanning.value = false
|
|
}
|
|
|
|
return found
|
|
}
|
|
|
|
async function scanBarcodesWithTimeout (image, ms) {
|
|
return await Promise.race([
|
|
scanBarcodes(image),
|
|
new Promise((_, reject) => setTimeout(
|
|
() => reject(new Error('ScanTimeout')),
|
|
ms,
|
|
)),
|
|
])
|
|
}
|
|
|
|
function isRetryable (e) {
|
|
const msg = (e?.message || '').toLowerCase()
|
|
return msg.includes('scantimeout')
|
|
|| msg.includes('failed to fetch')
|
|
|| msg.includes('networkerror')
|
|
|| msg.includes('load failed')
|
|
|| e?.name === 'TypeError' // fetch throws TypeError on network error
|
|
}
|
|
|
|
/**
|
|
* Resize 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()
|
|
img.onload = () => {
|
|
let { width, height } = img
|
|
if (width > maxDim || height > maxDim) {
|
|
const ratio = Math.min(maxDim / width, maxDim / height)
|
|
width = Math.round(width * ratio)
|
|
height = Math.round(height * ratio)
|
|
}
|
|
const canvas = document.createElement('canvas')
|
|
canvas.width = width
|
|
canvas.height = height
|
|
canvas.getContext('2d').drawImage(img, 0, 0, width, height)
|
|
resolve(canvas.toDataURL('image/jpeg', quality))
|
|
}
|
|
img.onerror = reject
|
|
img.src = URL.createObjectURL(file)
|
|
})
|
|
}
|
|
|
|
function removeBarcode (value) {
|
|
barcodes.value = barcodes.value.filter(b => b.value !== value)
|
|
}
|
|
|
|
function clearBarcodes () {
|
|
barcodes.value = []
|
|
error.value = null
|
|
lastPhoto.value = null
|
|
photos.value = []
|
|
}
|
|
|
|
return {
|
|
barcodes, scanning, error, lastPhoto, photos,
|
|
processPhoto, removeBarcode, clearBarcodes,
|
|
}
|
|
}
|