gigafibre-fsm/services/targo-hub/public/field-app.html
louispaulb 98458861c3 feat(dispatch): capacité AM/PM, durées additives, capture terrain (/field), sync techs
Hub (lib/roster.js, vision.js, legacy-dispatch-sync.js, server.js) + Ops + pont legacy.

- Capacité par jour AM/PM (Soir = réserve garde/urgence, jamais offerte) calculée
  client-side sur les techs visibles -> suit le filtre de compétence.
- Modèle de durée ADDITIF (caractéristiques, tableur inline) + auto-détection
  DÉTERMINISTE par mots-clés (sans IA permanente) ; est_min branché sur capacité + pool.
- Capture terrain passive : endpoints publics /field (job/tech/checkpoint/ts/photo/
  device/vision), tokens HMAC signés sans PII ; dérive actual_start/end. UI hébergée
  public/field-app.html (liste/carte Mapbox/Street View/photo/scan MLKit->Gemini).
- Chrono job (start/finish), repositionnement carte (set-location), vue satellite,
  Street View clic-droit, année devant les dates dues groupées.
- Sync techniciens : rapport de réconciliation 3 systèmes (staff legacy / Dispatch
  Technician / groupe Authentik), application MANUELLE, zéro écriture Authentik (+11 fiches).
- vision.js : extractEquipment() réutilisable (marque/modèle/série/MAC/codes-barres).
- Pont legacy (ops_reassign.php) : désassignation reflétée, fermeture ticket, retour
  au pool ; notification courriel à l'assignation.

Déployé sur le hub ; ce commit aligne le repo sur l'état en production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:47:21 -04:00

149 lines
14 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>
<link href="https://api.mapbox.com/mapbox-gl-js/v3.6.0/mapbox-gl.css" rel=stylesheet>
<script src="https://api.mapbox.com/mapbox-gl-js/v3.6.0/mapbox-gl.js"></script>
<style>
*{box-sizing:border-box}body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:#0f172a;color:#e2e8f0}
.wrap{max-width:640px;margin:0 auto;padding:12px;padding-bottom:80px}
.card{background:#1e293b;border-radius:14px;padding:14px;margin-bottom:10px}
h1{font-size:17px;margin:0 0 2px}.sub{color:#94a3b8;font-size:13px}
.badge{font-size:11px;font-weight:700;padding:2px 8px;border-radius:20px}
.b-todo{background:#334155;color:#cbd5e1}.b-run{background:#064e3b;color:#6ee7b7}.b-done{background:#1e3a8a;color:#bfdbfe}
.job{display:flex;gap:10px;align-items:center;cursor:pointer}
.map{height:230px;border-radius:10px;overflow:hidden;margin:8px 0}
.big{font-size:16px;font-weight:700;padding:15px;border-radius:12px;text-align:center;border:0;width:100%;color:#fff}
.start{background:#16a34a}.stop{background:#ea580c}.doneb{background:#1e40af}
.acts{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:10px}
.acts a,.acts button{background:#334155;color:#e2e8f0;border:0;border-radius:10px;padding:12px 6px;font-size:13px;text-align:center;text-decoration:none;font-weight:600}
.top{display:flex;align-items:center;gap:10px;margin-bottom:8px}
.lnk{color:#60a5fa;text-decoration:none;font-size:13px}
.geo{font-size:12px;color:#94a3b8;margin-top:6px}.warn{color:#fca5a5}
.modal{position:fixed;inset:0;background:rgba(0,0,0,.85);display:none;flex-direction:column;padding:14px;z-index:50}
.modal video{width:100%;border-radius:10px;background:#000}
.modal input{width:100%;padding:12px;border-radius:8px;border:1px solid #475569;background:#0f172a;color:#fff;font-size:15px;margin-top:8px}
.toast{position:fixed;bottom:14px;left:50%;transform:translateX(-50%);background:#0b3d2e;color:#6ee7b7;padding:10px 16px;border-radius:10px;font-size:13px;display:none;z-index:60}
.hbtn{background:#334155;border:0;color:#e2e8f0;border-radius:8px;padding:8px 10px;font-size:14px}
</style></head>
<body>
<div class=wrap>
<!-- LISTE -->
<div id=list>
<h1>Mes interventions <span class=sub id=techname></span></h1>
<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 style=margin-top:6px>
<a class=lnk id=ditin target=_blank>🧭 Itinéraire</a> &nbsp; <a class=lnk id=dsv target=_blank>👁 Street View</a>
</div>
<div class=map id=map></div>
<div class=geo id=dgeo></div>
<button id=chrono class="big start" onclick=toggleChrono()>📍 Je suis arrivé</button>
<div class=acts>
<a id=areply class=lnk style="background:#334155;border-radius:10px;padding:12px 6px;text-align:center" target=_blank>💬 Répondre</a>
<button onclick=document.getElementById('photo').click()>📷 Photo</button>
<button onclick=openScan()>📦 Appareil</button>
</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__', MAPBOX='__MAPBOX_TOKEN__', T=new URLSearchParams(location.search).get('t')||'';
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()}
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='Aucune intervention.';return}
$('jobs').innerHTML='';
JOBS.forEach((j,i)=>{const d=document.createElement('div');d.className='card job';d.onclick=()=>openDetail(i);
d.innerHTML='<div style=flex:1><div style=font-weight:600>'+esc(j.subject||'Job')+'</div><div class=sub>'+(j.start?j.start+' · ':'')+esc(j.address||'')+'</div></div>'+badge(j);
$('jobs').appendChild(d)});
}
function esc(s){return String(s||'').replace(/[<>&]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]))}
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=CUR.subject||'Job';$('dcust').textContent=CUR.customer||'';$('daddr').textContent='📍 '+(CUR.address||'(adresse manquante)');
$('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):'#';
$('areply').href=CUR.reply||'#';$('areply').style.opacity=CUR.reply?1:.4;
setChrono();initMap(hasLL);startWatch(hasLL);
}
function setChrono(){const b=$('chrono');if(CUR.ended){b.className='big doneb';b.textContent='✅ Terminé'+(CUR.minutes!=null?' · '+CUR.minutes+' min':'');b.disabled=true}
else if(CUR.started){b.className='big stop';b.textContent='⏹ J\'ai terminé';b.disabled=false}
else{b.className='big start';b.textContent='📍 Je suis arrivé';b.disabled=false}}
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){mapboxgl.accessToken=MAPBOX;map=new mapboxgl.Map({container:'map',style:'mapbox://styles/mapbox/streets-v12',center:hasLL?[+CUR.lon,+CUR.lat]:[-73.9,45.18],zoom:hasLL?15:9});
map.on('load',()=>{map.resize();if(hasLL){jobMk=new mapboxgl.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 mapboxgl.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=()=>{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);api('/field/photo',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:CUR.token,image:data})}).then(r=>toast(r&&r.ok?'📷 Photo 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>