gigafibre-fsm/services/targo-hub/public/field-app.html
louispaulb 4870cec09b hub+solveur : compétences multi par job + réservation + matérialisation quarts + app terrain natif
- roster.js : store /roster/job-skills (LISTE = source unique des compétences requises),
  enrichissement required_skills[] + required_skill (1re, rétro-compat), solveur techCovers
  (le tech doit les avoir TOUTES) ; /roster/reserve (bloc placeholder priorité moyenne, soft) ;
  publishWeek modes draft/submit/approve/publish ; matérialiseur de quarts récurrents
  (source:pattern) ; décodage des entités HTML aux points de service
- route_solver.py : skill_ok list-aware (toutes les compétences requises)
- dispatch.js : suggestSlots ignoreReserved (une réservation ne bloque pas la recherche de créneau)
- conversation.js : agentName sur le fil (commentaire au nom du tech, pas « Targo Ops »)
- legacy-dispatch-sync.js : postTicketLegacy(actorEmail, actorName) — commentaire attribué
- field-app.html + server.js : commentaire + fil de ticket NATIFS (restent sur OPS)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:46:42 -04:00

171 lines
18 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}
.dnote{display:none;margin-top:8px;background:#fff7ed;border:1px solid #fdba74;border-radius:10px;padding:10px;color:#7c2d12;font-size:14px;line-height:1.4}
.dnote .skl{margin-top:5px;font-size:12px;color:#9a3412}
.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 id=dnote class=dnote></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>
<button onclick=openComment()>💬 Commenter</button>
<button onclick=document.getElementById('photo').click()>📷 Photo</button>
<button onclick=openScan()>📦 Appareil</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__', 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()}
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='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(dec(j.subject)||'Job')+'</div><div class=sub>'+(j.start?j.start+' · ':'')+esc(dec(j.address))+'</div></div>'+badge(j);
$('jobs').appendChild(d)});
}
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');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=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>