gigafibre-fsm/services/targo-hub/public/field-app.html
louispaulb c1a240023b feat(field-app): refonte accessibilité terrain (gros texte/contraste/cibles + flux guidé)
Priorité accessibilité (usage terrain, gants, luminosité variable — utilisable par une personne âgée / focus TDAH) :
- Type scale +, contraste renforcé (sous-textes #94a3b8→#aebccf), cibles tactiles ≥54-58px (action principale, boutons d'action, cartes).
- Liste : emphase du PROCHAIN arrêt (ruban vert + bordure/halo), compteur « X de Y terminé(s) », arrêts terminés atténués.
- Détail : NAV + action principale au-dessus de la ligne de flottaison — « 🧭 Démarrer l'itinéraire » (bouton bleu proéminent) puis « 📍 Je suis arrivé » (grand vert) + phrase d'aide selon l'état ; carte descendue en référence ; Street View en lien discret ; boutons Commenter/Photo/Appareil agrandis (icône+libellé empilés).
- focus-visible + micro-feedback tactile (reduced-motion respecté).
Vérifié live (Playwright mobile, token tech réel) : liste + détail rendus, carte OSM/MapLibre (0 Mapbox).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 10:23:19 -04:00

211 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html><html lang=fr><head><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover,maximum-scale=1">
<title>Targo Tech</title>
<!-- Carte : MapLibre GL + tuiles OSM (aucun Mapbox, aucun jeton). -->
<link href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" rel=stylesheet>
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<style>
/* Accessibilité terrain (usage par temps/gants/lumière variables) : gros texte, fort contraste, grandes cibles tactiles,
une action principale claire par écran. */
*{box-sizing:border-box}body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:#0f172a;color:#e8eef6;font-size:17px;line-height:1.45;-webkit-text-size-adjust:100%}
.wrap{max-width:640px;margin:0 auto;padding:14px;padding-bottom:88px}
.card{background:#1e293b;border-radius:14px;padding:16px;margin-bottom:12px}
h1{font-size:21px;margin:0 0 3px;font-weight:800}.sub{color:#aebccf;font-size:14.5px}
.badge{font-size:12.5px;font-weight:700;padding:3px 11px;border-radius:20px;white-space:nowrap}
.b-todo{background:#334155;color:#e2e8f0}.b-run{background:#065f46;color:#a7f3d0}.b-done{background:#1e3a8a;color:#bfdbfe}
.job{display:flex;gap:12px;align-items:center;cursor:pointer;min-height:60px}
.job-next{border:2px solid #16a34a;box-shadow:0 0 0 3px rgba(22,163,74,.15)}
.job-done{opacity:.55}
.nextrib{display:inline-block;font-size:11px;font-weight:800;letter-spacing:.06em;color:#052e16;background:#4ade80;border-radius:6px;padding:1px 7px;margin-bottom:5px}
.map{height:190px;border-radius:12px;overflow:hidden;margin:10px 0}
.big{font-size:19px;font-weight:800;padding:18px;border-radius:14px;text-align:center;border:0;width:100%;color:#fff;min-height:58px}
.start{background:#16a34a}.stop{background:#ea580c}.doneb{background:#1e40af}
.hint{text-align:center;color:#aebccf;font-size:13.5px;margin-top:8px}
.navbtn{display:block;width:100%;background:#2563eb;color:#fff;font-weight:800;font-size:17px;padding:16px;border:0;border-radius:14px;text-align:center;text-decoration:none;margin:10px 0;min-height:54px}
.dnote{display:none;margin-top:10px;background:#fff7ed;border:1px solid #fdba74;border-radius:12px;padding:12px;color:#7c2d12;font-size:15px;line-height:1.45}
.dnote .skl{margin-top:6px;font-size:13.5px;color:#9a3412}
.acts{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-top:12px}
.acts a,.acts button{background:#334155;color:#e8eef6;border:0;border-radius:12px;padding:15px 6px;font-size:14.5px;text-align:center;text-decoration:none;font-weight:700;min-height:56px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px}
.top{display:flex;align-items:center;gap:12px;margin-bottom:10px}
.lnk{color:#7cb0ff;text-decoration:none;font-size:15px;font-weight:600}
.geo{font-size:13.5px;color:#aebccf;margin-top:8px}.warn{color:#fca5a5;font-weight:600}
.modal{position:fixed;inset:0;background:rgba(0,0,0,.9);display:none;flex-direction:column;padding:16px;z-index:50}
.modal video{width:100%;border-radius:12px;background:#000}
.modal input{width:100%;padding:14px;border-radius:10px;border:1px solid #475569;background:#0f172a;color:#fff;font-size:16px;margin-top:10px}
.toast{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:#0b3d2e;color:#a7f3d0;padding:12px 18px;border-radius:12px;font-size:14.5px;display:none;z-index:60;font-weight:600}
.hbtn{background:#334155;border:0;color:#e8eef6;border-radius:10px;padding:11px 14px;font-size:15px;font-weight:600}
:focus-visible{outline:3px solid #7cb0ff;outline-offset:2px}
@media (prefers-reduced-motion:no-preference){.big{transition:transform .08s}.big:active{transform:scale(.98)}}
</style></head>
<body>
<div class=wrap>
<!-- LISTE -->
<div id=list>
<h1>Mes interventions <span class=sub id=techname></span></h1>
<button id=bestroute onclick=bestRoute() style="display:none;width:100%;background:#0e7c7b;color:#fff;border:0;border-radius:12px;padding:13px;font-size:15px;font-weight:700;margin:6px 0 10px">🚀 Meilleur trajet du jour (trafic en direct)</button>
<div id=jobs class=sub>Chargement…</div>
</div>
<!-- DÉTAIL -->
<div id=detail style=display:none>
<div class=top><button class=hbtn onclick=showList()> Retour</button><span class=badge id=dbadge></span></div>
<div class=card>
<h1 id=dsub></h1><div class=sub id=dcust></div>
<div class=sub id=daddr style=margin-top:4px></div>
<div id=dnote class=dnote></div>
<!-- Nav + action principale D'ABORD (au-dessus de la ligne de flottaison) ; carte = référence en dessous. -->
<a class=navbtn id=ditin target=_blank>🧭 Démarrer l'itinéraire</a>
<button id=chrono class="big start" onclick=toggleChrono()>📍 Je suis arrivé</button>
<div class=hint id=chronohint></div>
<div class=geo id=dgeo></div>
<div class=map id=map></div>
<div style="text-align:center;margin-top:8px"><a class=lnk id=dsv target=_blank>👁 Voir en Street View</a></div>
<div class=acts>
<button onclick=openComment()>💬<span>Commenter</span></button>
<button onclick=document.getElementById('photo').click()>📷<span>Photo</span></button>
<button onclick=openScan()>📦<span>Appareil</span></button>
</div>
<div id=cmtbox style="display:none;margin-top:8px;background:#0f172a;border:1px solid #334155;border-radius:10px;padding:10px;text-align:left">
<div style="font-size:12px;font-weight:700;color:#94a3b8;margin-bottom:4px">💬 Fil du ticket</div>
<div id=cmthread style="max-height:210px;overflow:auto;margin-bottom:8px;font-size:13px;line-height:1.4;color:#e2e8f0">Chargement…</div>
<textarea id=cmtxt placeholder="Écrire une note / un commentaire…" style="width:100%;box-sizing:border-box;min-height:64px;border-radius:8px;border:1px solid #475569;background:#1e293b;color:#e2e8f0;padding:8px;font-size:15px"></textarea>
<label style="display:flex;align-items:center;gap:6px;margin:8px 0;font-size:13px;color:#cbd5e1"><input type=checkbox id=cmpub> 📣 Envoyer aussi au client (réponse publique)</label>
<div style="display:flex;gap:8px">
<button onclick=sendComment() style="flex:1;background:#6366f1;color:#fff;font-weight:700;font-size:15px;padding:13px;border:0;border-radius:10px">Ajouter au ticket</button>
<button onclick=closeComment() style="flex:0 0 96px;background:#475569;color:#fff;font-weight:600;font-size:15px;padding:13px;border:0;border-radius:10px">Fermer</button>
</div>
</div>
<input type=file id=photo accept=image/* capture=environment style=display:none onchange=sendPhoto(this)>
</div>
</div>
</div>
<!-- SCAN -->
<div class=modal id=scan>
<div class=top><button class=hbtn onclick=closeScan()>✕ Fermer</button><span class=sub>Scanner code-barre / saisir</span></div>
<video id=cam playsinline></video>
<button class=big style="background:#7c3aed;margin-top:8px" onclick=visionScan()>📸 Lire l'étiquette (IA)</button>
<div class=sub id=vismsg style=margin-top:4px></div>
<input type=file id=scanphoto accept=image/* capture=environment style=display:none onchange=visionFile(this)>
<input id=serial placeholder="N° de série">
<input id=mac placeholder="MAC (optionnel)">
<button class="big doneb" style=margin-top:10px onclick=sendDevice()>Enregistrer l'appareil</button>
</div>
<div class=toast id=toast></div>
<script>
const HUB='__HUB__', T=new URLSearchParams(location.search).get('t')||'';
// Style MapLibre = tuiles raster OSM (libre, sans jeton). Attribution © OpenStreetMap requise (ODbL).
const OSM_STYLE={version:8,sources:{osm:{type:'raster',tiles:['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png','https://b.tile.openstreetmap.org/{z}/{x}/{y}.png','https://c.tile.openstreetmap.org/{z}/{x}/{y}.png'],tileSize:256,attribution:'© OpenStreetMap'}},layers:[{id:'osm',type:'raster',source:'osm'}]};
const CAP=window.Capacitor, NATIVE=!!(CAP&&CAP.isNativePlatform&&CAP.isNativePlatform()), MLKIT=CAP&&CAP.Plugins&&CAP.Plugins.BarcodeScanning;
const $=id=>document.getElementById(id); let JOBS=[],CUR=null,map=null,jobMk=null,meMk=null,watch=null,enteredZone=false;
function toast(m){const t=$('toast');t.textContent=m;t.style.display='block';setTimeout(()=>t.style.display='none',2600)}
function badge(j){return j.ended?'<span class="badge b-done">terminé'+(j.minutes!=null?' · '+j.minutes+'m':'')+'</span>':j.started?'<span class="badge b-run">en cours</span>':'<span class="badge b-todo">à faire</span>'}
async function api(p,o){const r=await fetch(HUB+p,o);return r.json()}
var _cmPoll=null;
function openComment(){var b=$('cmtbox');var show=(b.style.display==='none'||!b.style.display);if(show){b.style.display='block';$('cmtxt').focus();loadThread();if(_cmPoll)clearInterval(_cmPoll);_cmPoll=setInterval(loadThread,15000)}else{closeComment()}}
function closeComment(){$('cmtbox').style.display='none';if(_cmPoll){clearInterval(_cmPoll);_cmPoll=null}}
async function loadThread(){if(!CUR||!CUR.token)return;var r=await api('/field/thread?token='+encodeURIComponent(CUR.token)).catch(function(){return{ok:false}});var el=$('cmthread');if(!el)return;if(!r||!r.ok){el.innerHTML='<div style="color:#94a3b8">Fil indisponible</div>';return}var ms=r.messages||[];if(!ms.length){el.innerHTML='<div style="color:#94a3b8">Aucun message pour l\'instant.</div>';return}el.innerHTML=ms.slice(-40).map(function(m){var d=m.at?new Date(m.at).toLocaleString('fr-CA',{dateStyle:'short',timeStyle:'short'}):'';var txt=esc(String(m.text||m.msg||'').replace(/<[^>]+>/g,' ').replace(/&nbsp;/g,' ').trim()).slice(0,700);var who=m.fromClient?'<span style="color:#fbbf24;font-size:10px;font-weight:700">CLIENT</span> ':'';return '<div style="border-bottom:1px solid #1e293b;padding:5px 0">'+who+'<b style="color:#a5b4fc">'+esc(m.author||'—')+'</b> <span style="color:#64748b;font-size:11px">'+d+'</span><br><span style="white-space:pre-wrap">'+txt+'</span></div>'}).join('');el.scrollTop=el.scrollHeight}
async function sendComment(){var t=($('cmtxt').value||'').trim();if(!t){toast('Écris un commentaire');return}var pub=$('cmpub').checked;var r=await api('/field/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:CUR.token,text:t,public:pub})}).catch(function(){return{ok:false}});if(r&&r.ok){$('cmtxt').value='';$('cmpub').checked=false;toast(pub?(r.sentToCustomer?'✓ Ajouté + envoyé au client':'✓ Ajouté (client non joignable)'):'✓ Note interne ajoutée');loadThread()}else{toast('Échec — réessaie')}}
function dist(a1,o1,a2,o2){const R=6371000,t=Math.PI/180,dA=(a2-a1)*t,dO=(o2-o1)*t;const x=Math.sin(dA/2)**2+Math.cos(a1*t)*Math.cos(a2*t)*Math.sin(dO/2)**2;return Math.round(R*2*Math.atan2(Math.sqrt(x),Math.sqrt(1-x)))}
async function load(){
const r=await api('/field/tech?t='+encodeURIComponent(T)).catch(()=>({}));
if(r&&r.ok){JOBS=r.jobs||[];$('techname').textContent='· '+(r.tech||'');renderList();return}
// sinon : token de job unique
const j=await api('/field/job?t='+encodeURIComponent(T)).catch(()=>({}));
if(j&&j.ok){JOBS=[{...j.job,token:T,start:'',started:!!j.job.actual_start,ended:!!j.job.actual_end,minutes:j.job.actual_minutes,lat:j.job.latitude,lon:j.job.longitude}];openDetail(0);return}
$('jobs').innerHTML='<span class=warn>Lien invalide ou expiré.</span>';
}
function renderList(){
if(!JOBS.length){$('jobs').innerHTML='<div class=sub>Aucune intervention aujourd\'hui.</div>';$('bestroute').style.display='none';return}
const done=JOBS.filter(j=>j.ended).length; const nextIdx=JOBS.findIndex(j=>!j.ended); // 1er arrêt non terminé = « prochain »
$('jobs').innerHTML='<div class=sub style="margin:2px 0 12px">'+done+' de '+JOBS.length+' terminé(s)</div>';
JOBS.forEach((j,i)=>{const d=document.createElement('div');
d.className='card job'+(i===nextIdx?' job-next':'')+(j.ended?' job-done':'');
d.onclick=()=>openDetail(i);
d.innerHTML='<div style=flex:1>'+(i===nextIdx?'<div class=nextrib>PROCHAIN</div>':'')+'<div style="font-weight:700;font-size:16px">'+esc(dec(j.subject)||'Job')+'</div><div class=sub>'+(j.start?j.start+' · ':'')+esc(dec(j.address))+'</div></div>'+badge(j);
$('jobs').appendChild(d)});
// Bouton « meilleur trajet » dès qu'il reste ≥2 arrêts géolocalisés non terminés (sinon 1 seul → « Itinéraire » du détail suffit).
const stops=JOBS.filter(j=>!j.ended&&j.lat&&Math.abs(+j.lat)>0.01);
$('bestroute').style.display=stops.length>=2?'block':'none';
}
// MEILLEUR TRAJET : l'ordre optimal des arrêts vient de NOTRE OSRM (OSM, sans trafic) ; la navigation réelle est
// remise au GPS du téléphone (Google Maps) qui applique le TRAFIC EN DIRECT gratuitement. Zéro Mapbox.
async function bestRoute(){
const btn=$('bestroute');btn.disabled=true;const lbl=btn.textContent;btn.textContent='⏳ Calcul du trajet…';
let origin=null;try{const p=await getPos();origin=p.coords.latitude.toFixed(6)+','+p.coords.longitude.toFixed(6)}catch(e){}
let r=null;try{r=await api('/field/route?t='+encodeURIComponent(T)+(origin?'&from='+origin:''))}catch(e){}
btn.disabled=false;btn.textContent=lbl;
const stops=(r&&r.ok&&r.stops&&r.stops.length)?r.stops:JOBS.filter(j=>!j.ended&&j.lat&&Math.abs(+j.lat)>0.01).map(j=>({lat:j.lat,lon:j.lon}));
if(!stops.length){toast('Aucun arrêt géolocalisé');return}
const pts=stops.map(s=>s.lat+','+s.lon);
const dest=pts[pts.length-1], way=pts.slice(0,-1);
// Google Maps applique le trafic en direct en mode driving. origin omis = position courante ; sinon GPS mesuré.
let url='https://www.google.com/maps/dir/?api=1&travelmode=driving&destination='+encodeURIComponent(dest);
if(origin)url+='&origin='+encodeURIComponent(origin);
if(way.length)url+='&waypoints='+encodeURIComponent(way.join('|'));
window.open(url,'_blank');
toast('🚀 '+pts.length+' arrêt(s)'+(r&&r.ordered?' · ordre optimisé':''));
}
function esc(s){return String(s||'').replace(/[<>&]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]))}
function dec(s){var v=String(s||'');if(v.indexOf('&')<0)return v;for(var i=0;i<2;i++){var t=document.createElement('textarea');t.innerHTML=v;if(t.value===v)break;v=t.value}return v} // décode les entités HTML (&#039; &eacute; …) venues d'osTicket AVANT affichage
function showList(){if(watch){navigator.geolocation.clearWatch(watch);watch=null}if(map){map.remove();map=null}$('detail').style.display='none';$('list').style.display='block';load()}
function openDetail(i){
CUR=JOBS[i];enteredZone=false;$('list').style.display='none';$('detail').style.display='block';
$('dsub').textContent=dec(CUR.subject)||'Job';$('dcust').textContent=dec(CUR.customer);$('daddr').textContent='📍 '+(dec(CUR.address)||'(adresse manquante)');
var dn=$('dnote'),nt=dec(CUR.notes).trim(),sk=dec(CUR.skill);dn.innerHTML=(nt?('<b>📌 Note importante</b><br>'+esc(nt)):'')+(sk?('<div class=skl>🔧 '+esc(sk)+'</div>'):'');dn.style.display=(nt||sk)?'block':'none';
$('dbadge').innerHTML=badge(CUR);
const hasLL=CUR.lat&&Math.abs(+CUR.lat)>0.01;
$('ditin').href=hasLL?('https://www.google.com/maps/dir/?api=1&destination='+CUR.lat+','+CUR.lon):'#';
$('dsv').href=hasLL?('https://www.google.com/maps/@?api=1&map_action=pano&viewpoint='+CUR.lat+','+CUR.lon):'#';
setChrono();initMap(hasLL);startWatch(hasLL);
}
function setChrono(){const b=$('chrono');const h=$('chronohint');
if(CUR.ended){b.className='big doneb';b.textContent='✅ Terminé'+(CUR.minutes!=null?' · '+CUR.minutes+' min':'');b.disabled=true;if(h)h.textContent='Intervention terminée.'}
else if(CUR.started){b.className='big stop';b.textContent='⏹ J\'ai terminé';b.disabled=false;if(h)h.textContent='Appuyez quand le travail est terminé.'}
else{b.className='big start';b.textContent='📍 Je suis arrivé';b.disabled=false;if(h)h.textContent='Appuyez en arrivant (détecté aussi automatiquement).'}}
async function toggleChrono(){
let pos=null;try{pos=await getPos()}catch(e){}
const type=CUR.started?'geo_exit':'geo_enter';
const r=await checkpoint(type,pos);
if(r&&r.ok){if(type==='geo_enter'){CUR.started=true}else{CUR.ended=true;CUR.minutes=r.minutes}setChrono();$('dbadge').innerHTML=badge(CUR);toast(type==='geo_enter'?'Arrivée enregistrée':'Terminé · '+(r.minutes||0)+' min')}
}
async function checkpoint(type,pos){const body={token:CUR.token,type};if(pos){body.lat=pos.coords.latitude;body.lon=pos.coords.longitude;body.acc=pos.coords.accuracy}
return api('/field/checkpoint',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).catch(()=>({ok:false}))}
function getPos(){return new Promise((res,rej)=>navigator.geolocation.getCurrentPosition(res,rej,{enableHighAccuracy:true,timeout:10000}))}
function initMap(hasLL){map=new maplibregl.Map({container:'map',style:OSM_STYLE,center:hasLL?[+CUR.lon,+CUR.lat]:[-73.9,45.18],zoom:hasLL?15:9});
map.addControl(new maplibregl.NavigationControl({showCompass:false}),'top-right');
map.on('load',()=>{map.resize();if(hasLL){jobMk=new maplibregl.Marker({color:'#ea580c'}).setLngLat([+CUR.lon,+CUR.lat]).addTo(map)}})}
function startWatch(hasLL){if(!navigator.geolocation)return;
watch=navigator.geolocation.watchPosition(p=>{const ll=[p.coords.longitude,p.coords.latitude];
if(map){if(!meMk){meMk=new maplibregl.Marker({color:'#38bdf8'}).setLngLat(ll).addTo(map)}else meMk.setLngLat(ll)}
if(hasLL){const d=dist(+CUR.lat,+CUR.lon,p.coords.latitude,p.coords.longitude);$('dgeo').textContent='à '+d+' m de l\'adresse (±'+Math.round(p.coords.accuracy)+' m)';
if(d<=250&&!enteredZone&&!CUR.started){enteredZone=true;CUR.started=true;setChrono();$('dbadge').innerHTML=badge(CUR);checkpoint('geo_enter',p);toast('Arrivée détectée (auto)')}}
},e=>{$('dgeo').innerHTML='<span class=warn>Active la localisation pour le suivi auto</span>'},{enableHighAccuracy:true,maximumAge:15000,timeout:20000})}
// PHOTO : redimensionne puis envoie en base64
function sendPhoto(inp){const f=inp.files[0];if(!f)return;const img=new Image();const rd=new FileReader();
rd.onload=()=>{img.onload=async()=>{const s=Math.min(1,1280/Math.max(img.width,img.height));const c=document.createElement('canvas');c.width=img.width*s;c.height=img.height*s;c.getContext('2d').drawImage(img,0,0,c.width,c.height);
const data=c.toDataURL('image/jpeg',0.7);
// Localisation de l'appareil au moment de la photo (best-effort) → le hub confirme qu'on est au bon endroit. Refus/erreur = photo envoyée quand même.
var body={token:CUR.token,image:data};try{const p=await getPos();body.lat=+p.coords.latitude.toFixed(6);body.lon=+p.coords.longitude.toFixed(6);body.acc=Math.round(p.coords.accuracy||0)}catch(e){}
api('/field/photo',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(r=>toast(r&&r.ok?('📷 Photo'+(r.onsite===true?' · ✓ sur place':(r.distLabel?' · ⚠ à '+r.distLabel:' envoyée'))):'Échec photo'))};img.src=rd.result};rd.readAsDataURL(f);inp.value=''}
// SCAN : BarcodeDetector (Android) sinon saisie manuelle ; en build natif, plugin MLKit prend le relais
let scanStream=null,scanTimer=null;
async function openScan(){$('scan').style.display='flex';$('serial').value='';$('mac').value='';$('vismsg').textContent='';
if(MLKIT){$('cam').style.display='none';mlkitScan();return} // NATIF : MLKit on-device (instantané, hors-ligne) — pas de latence Gemini
try{scanStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:'environment'}});$('cam').srcObject=scanStream;$('cam').play();$('cam').style.display='block';
if('BarcodeDetector'in window){const det=new BarcodeDetector();scanTimer=setInterval(async()=>{try{const codes=await det.detect($('cam'));if(codes[0]){const v=codes[0].rawValue;if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(v))$('mac').value=v;else $('serial').value=v;navigator.vibrate&&navigator.vibrate(80)}}catch(e){}},700)}
}catch(e){$('cam').style.display='none'}}
function closeScan(){if(scanTimer)clearInterval(scanTimer);if(scanStream){scanStream.getTracks().forEach(t=>t.stop());scanStream=null}$('scan').style.display='none'}
// MLKit natif (on-device, hors-ligne, instantané) — via le plugin Capacitor @capacitor-mlkit/barcode-scanning
async function mlkitScan(){try{$('vismsg').textContent='⚡ Scanner…';const r=await MLKIT.scan();const codes=(r&&r.barcodes)||[];
if(codes.length){const v=codes[0].rawValue||codes[0].displayValue||'';if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(v))$('mac').value=v.replace(/[:-]/g,'').toUpperCase();else $('serial').value=v;$('vismsg').textContent='✓ '+v;navigator.vibrate&&navigator.vibrate(80)}
else $('vismsg').textContent='Aucun code-barre — « Lire l\'étiquette (IA) » pour du texte'}catch(e){$('vismsg').textContent='Scan annulé'}}
// Photo de l'étiquette → proxy IA Gemini (cross-plateforme, lit le texte imprimé, pas que les code-barres)
function visionScan(){if(scanStream&&$('cam').videoWidth){const v=$('cam');const c=document.createElement('canvas');c.width=v.videoWidth;c.height=v.videoHeight;c.getContext('2d').drawImage(v,0,0);visionUpload(c.toDataURL('image/jpeg',0.7))}else $('scanphoto').click()}
function visionFile(inp){const f=inp.files[0];if(!f)return;const img=new Image(),rd=new FileReader();rd.onload=()=>{img.onload=()=>{const s=Math.min(1,1280/Math.max(img.width,img.height));const c=document.createElement('canvas');c.width=img.width*s;c.height=img.height*s;c.getContext('2d').drawImage(img,0,0,c.width,c.height);visionUpload(c.toDataURL('image/jpeg',0.7))};img.src=rd.result};rd.readAsDataURL(f);inp.value=''}
async function visionUpload(data){$('vismsg').textContent='🔎 Lecture IA…';const r=await api('/field/vision',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:CUR.token,image:data})}).catch(()=>({ok:false}));
if(r&&r.ok){if(r.serial_number)$('serial').value=r.serial_number;if(r.mac_address)$('mac').value=r.mac_address;$('vismsg').textContent='✓ '+([r.brand,r.model].filter(Boolean).join(' ')+(r.serial_number?' · SN '+r.serial_number:'')+(r.mac_address?' · MAC '+r.mac_address:'')||'lu, vérifie les champs');navigator.vibrate&&navigator.vibrate(80)}else $('vismsg').textContent='Échec lecture IA — saisis à la main'}
async function sendDevice(){const serial=$('serial').value.trim(),mac=$('mac').value.trim();if(!serial&&!mac){toast('Série ou MAC requis');return}
const r=await api('/field/device',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:CUR.token,serial,mac})}).catch(()=>({ok:false}));
if(r&&r.ok){CUR.started=true;setChrono();$('dbadge').innerHTML=badge(CUR);toast('📦 Appareil enregistré');closeScan()}else toast('Échec')}
load();
</script></body></html>