Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré / juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts, sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle week-end + placeholder « en attente du quart », clustering + lasso + filtre-date sur la carte, accès rapide « À assigner » (badge). Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive + timeout fetch global + authFetch durci → fin des rechargements manuels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
153 lines
14 KiB
HTML
153 lines
14 KiB
HTML
<!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> <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=>({'<':'<','>':'>','&':'&'}[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)');
|
||
var dn=$('dnote'),nt=(CUR.notes||'').trim(),sk=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):'#';
|
||
$('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>
|