gigafibre-fsm/apps/field/src/composables/useScanner.js
louispaulb 41d9b5f316 feat: flow editor, Gemini QR scanner with offline queue, dispatch planning v2
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>
2026-04-22 10:44:17 -04:00

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,
}
}