- 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>
171 lines
18 KiB
HTML
171 lines
18 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>
|
||
<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(/ /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=>({'<':'<','>':'>','&':'&'}[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 (' é …) 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>
|