gigafibre-fsm/services/targo-hub/lib/ui/scanner.js
louispaulb 169426a6d8 refactor(targo-hub): extract ui/ kit, migrate tech-mobile to it
Introduces services/targo-hub/lib/ui/ as the shared kit for every magic-link
page served from the hub (tech mobile, acceptance, payments):

  design.css    tokens (--brand, --success, etc) + reset + all primitives
  components.js server-side HTML builders (badge/section/card/panel/statRow/
                tabBar) + shared date helpers (fmtTime/dateLabelFr/montrealDate)
                + canonical STATUS_META
  client.js     client-side api wrapper ($, toast, api.get/post, router.on/go)
                baked into every page — no more hand-rolled fetch+hashchange
  scanner.js    Gemini field-scan overlay (window.scanner.open(field,label,cb,ctx))
  shell.js      ui.page({title, body, bootVars, cfg, script, includeScanner})
                inlines everything into one self-contained HTML doc
  index.js      barrel

Migrates tech-mobile.js to the kit:
  - drops inline esc/toast/fmtTime/dlbl/STATUS_META/badge helpers
  - api.post('/status', {...}) instead of fetch(H+'/t/'+T+'/status', {...})
  - router.on('#job/:name', handler) instead of hand-rolled route()
  - scanner.open(field, label, cb, ctx) instead of ~60 lines of field-scan logic

Behavior preserved — rendered HTML keeps tabs, detail view, notes editor,
photo upload, per-field Gemini scans, Montreal-TZ date labels, v16 link-label
resolution. Verified live at msg.gigafibre.ca with a real TECH-4 token.

Sets up acceptance.js and payments.js to drop from ~700 → ~300 lines each
in the next commits by consuming the same primitives.
2026-04-22 22:47:19 -04:00

114 lines
4.9 KiB
JavaScript

// ─────────────────────────────────────────────────────────────────────────────
// Field-targeted scanner overlay (Gemini single-value extraction).
// Opt-in: pages include this when ui.page({ includeScanner: true }).
//
// Usage from page code:
// scanner.open('serial_number', 'Numéro de série', function (value) {
// // value is confirmed by the tech; write it into the target input.
// }, { equipment_type, brand, model })
//
// The page must also include <div id="fsOv"> markup, which shell.js injects
// automatically when includeScanner is on.
// ─────────────────────────────────────────────────────────────────────────────
(function () {
var stream = null
var field = ''
var fieldLabel = ''
var ctx = {}
var callback = null
function $ (id) { return document.getElementById(id) }
function esc (s) { return window._esc(s) }
function open (fieldKey, label, cb, context) {
field = fieldKey; fieldLabel = label; callback = cb; ctx = context || {}
$('fsLabel').textContent = 'Scanner : ' + label
$('fsResult').innerHTML = '<div class="sr sm ta-c">Cadrez l\'étiquette puis appuyez sur <b>Capturer</b></div>'
$('fsOv').classList.add('open')
startCam()
}
function close () {
$('fsOv').classList.remove('open')
stopCam()
callback = null
}
function startCam () {
var v = $('fsVid')
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
v.style.display = 'none'
$('fsResult').innerHTML = '<div class="sr" style="color:#ef4444">Caméra indisponible</div>'
return
}
navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
}).then(function (s) {
stream = s
v.srcObject = s
v.style.display = 'block'
}).catch(function (e) {
$('fsResult').innerHTML = '<div class="sr" style="color:#ef4444">Caméra refusée: ' + (e.message || '') + '</div>'
})
}
function stopCam () {
if (stream) { stream.getTracks().forEach(function (t) { t.stop() }); stream = null }
var v = $('fsVid'); if (v) v.srcObject = null
}
function capture () {
var v = $('fsVid'), c = $('fsCnv')
if (!v.videoWidth) { window.toast('Caméra non prête', false); return }
c.width = v.videoWidth; c.height = v.videoHeight
var cctx = c.getContext('2d'); cctx.drawImage(v, 0, 0, c.width, c.height)
var b64 = c.toDataURL('image/jpeg', 0.85)
$('fsResult').innerHTML = '<div class="sr sm ta-c">🤖 Analyse par Gemini Vision…</div>'
var payload = { image: b64, field: field }
if (ctx.equipment_type) payload.equipment_type = ctx.equipment_type
if (ctx.brand) payload.brand = ctx.brand
if (ctx.model) payload.model = ctx.model
if (ctx.hint) payload.hint = ctx.hint
window.api.post('/field-scan', payload).then(function (r) {
var d = r.data || {}
if (!d.ok || !d.value) {
$('fsResult').innerHTML = '<div class="sr" style="color:#ef4444;text-align:center">❌ Non détecté — rapprochez-vous et réessayez.</div>'
return
}
var pct = Math.round((d.confidence || 0) * 100)
var color = pct >= 70 ? '#22c55e' : pct >= 40 ? '#f59e0b' : '#ef4444'
$('fsResult').innerHTML =
'<div class="sr">' +
'<div class="fs-val">' + esc(d.value) + '</div>' +
'<div class="sm ta-c">Confiance : <b style="color:' + color + '">' + pct + '%</b></div>' +
'<div class="conf-bar"><div class="f" style="width:' + pct + '%;background:' + color + '"></div></div>' +
'<div style="display:flex;gap:8px;margin-top:12px">' +
'<button class="btn btn-sm" style="background:' + color + ';color:#fff;flex:1" onclick="scanner.confirm(&quot;' + esc(d.value) + '&quot;)">✓ Utiliser</button>' +
'<button class="btn btn-sm btn-muted" onclick="scanner.capture()">↻ Réessayer</button>' +
'</div>' +
'</div>'
}).catch(function (e) {
$('fsResult').innerHTML = '<div class="sr" style="color:#ef4444">Erreur réseau : ' + (e.message || '') + '</div>'
})
}
function confirm (value) {
if (callback) callback(value)
close()
}
// Bind close button + ESC
window.addEventListener('load', function () {
var x = document.querySelector('#fsOv .fs-close')
if (x) x.addEventListener('click', close)
var cap = document.querySelector('#fsOv .fs-capture')
if (cap) cap.addEventListener('click', capture)
var can = document.querySelector('#fsOv .fs-cancel')
if (can) can.addEventListener('click', close)
})
window.scanner = { open: open, close: close, capture: capture, confirm: confirm }
})()