gigafibre-fsm/services/targo-hub/lib/acte-form.html
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
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>
2026-07-02 08:49:35 -04:00

221 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">
<title>Targo — Actes du ticket</title>
<style>
:root{ --bg:#f4f5f7; --card:#fff; --ink:#1f2933; --mut:#7b8794; --line:#e4e7eb; --accent:#0b7285; --on:#0b7285; --okbg:#e6fcf5; --warn:#b54708; }
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-text-size-adjust:100%}
.wrap{max-width:560px;margin:0 auto;padding:16px 14px 130px}
header{padding:6px 2px 14px}
.tk{font-size:13px;color:var(--mut);font-weight:600;letter-spacing:.02em}
h1{font-size:21px;margin:4px 0 2px;font-weight:700}
.meta{font-size:13px;color:var(--mut)}
.sec{font-size:12px;font-weight:700;color:var(--mut);text-transform:uppercase;letter-spacing:.04em;margin:18px 4px 6px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;overflow:hidden;margin-top:6px}
.row{display:flex;align-items:center;gap:12px;padding:13px 14px;border-top:1px solid var(--line)}
.row:first-child{border-top:0}
.row .lab{flex:1;font-weight:600;font-size:15px}
.row.grp{flex-direction:column;align-items:stretch;gap:10px}
.row.grp .lab{flex:none}
.tg{position:relative;width:52px;height:30px;flex:0 0 auto;cursor:pointer}
.tg input{opacity:0;width:0;height:0;position:absolute}
.tg .tr{position:absolute;inset:0;background:#cbd2d9;border-radius:999px;transition:.15s}
.tg .tr:before{content:"";position:absolute;height:24px;width:24px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.15s;box-shadow:0 1px 3px rgba(0,0,0,.3)}
.tg input:checked + .tr{background:var(--accent)}
.tg input:checked + .tr:before{transform:translateX(22px)}
.tg input:focus-visible + .tr{outline:3px solid #a5d8e2}
.seg{display:flex;flex-wrap:wrap;gap:8px}
.opt{appearance:none;border:1.5px solid var(--line);background:#fff;color:var(--ink);border-radius:10px;padding:11px 14px;font-size:14px;font-weight:600;cursor:pointer;flex:1 1 auto;min-width:84px;text-align:center;transition:.12s}
.opt:focus-visible{outline:3px solid #a5d8e2}
.opt.on{background:var(--on);border-color:var(--on);color:#fff}
.seg.count .opt{flex:0 0 auto;min-width:46px;padding:11px 0}
.step{display:flex;align-items:center;gap:0}
.step button{appearance:none;width:38px;height:42px;border:1.5px solid var(--line);background:#fff;font-size:22px;line-height:1;color:var(--accent);cursor:pointer}
.step button:first-child{border-radius:10px 0 0 10px}
.step button:last-child{border-radius:0 10px 10px 0}
.step input{width:72px;height:42px;border:1.5px solid var(--line);border-left:0;border-right:0;text-align:center;font-size:16px;font-weight:700;color:var(--ink);-moz-appearance:textfield}
.step input:focus{outline:none;border-color:var(--accent)}
.step input::-webkit-outer-spin-button,.step input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
.rev{display:flex;align-items:center;gap:10px;padding-left:2px}
.rev .cap{font-size:13px;color:var(--mut)}
.unit{font-size:12px;color:var(--mut);min-width:16px}
.ph{padding:14px}
.addph{appearance:none;width:100%;border:1.5px dashed var(--line);background:#fbfcfd;color:var(--accent);border-radius:12px;padding:14px;font-size:15px;font-weight:700;cursor:pointer}
#noteIn{width:100%;border:1.5px solid var(--line);border-radius:10px;padding:12px;font-family:inherit;font-size:15px;resize:vertical;min-height:84px;color:var(--ink)}
#noteIn:focus{outline:none;border-color:var(--accent)}
.thumbs{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.thumb{position:relative;width:76px;height:76px;border-radius:10px;overflow:hidden;border:1px solid var(--line)}
.thumb img{width:100%;height:100%;object-fit:cover}
.thumb b{position:absolute;top:2px;right:2px;width:20px;height:20px;line-height:18px;text-align:center;background:rgba(0,0,0,.6);color:#fff;border-radius:50%;font-size:13px;cursor:pointer}
.thumb i.geo{position:absolute;left:2px;bottom:1px;font-size:13px;font-style:normal;filter:drop-shadow(0 0 2px #000)}
.cert{display:flex;gap:11px;align-items:flex-start;padding:16px 14px;margin-top:14px;background:#fff;border:1px solid var(--line);border-radius:14px}
.cert input{width:22px;height:22px;margin-top:1px;flex:0 0 auto;accent-color:var(--accent)}
.cert label{font-size:13.5px;color:var(--ink)}
.infruct{display:block;width:100%;margin-top:12px;background:none;border:0;color:var(--warn);font-size:13.5px;font-weight:600;text-decoration:underline;cursor:pointer;padding:8px}
.bar{position:fixed;left:0;right:0;bottom:0;background:#fff;border-top:1px solid var(--line);padding:12px 14px calc(12px + env(safe-area-inset-bottom));box-shadow:0 -6px 18px rgba(0,0,0,.05)}
.bar .inner{max-width:560px;margin:0 auto;display:flex;align-items:center;gap:12px}
.tot{flex:1;font-size:12px;color:var(--mut)}
.tot b{display:block;font-size:18px;color:var(--ink);font-weight:700}
.go{appearance:none;border:0;background:var(--accent);color:#fff;font-size:16px;font-weight:700;padding:14px 22px;border-radius:12px;cursor:pointer}
.go:disabled{opacity:.4}
.msg{padding:40px 18px;text-align:center}
.msg .big{font-size:48px}
.done{background:var(--okbg);border-color:#63e6be}
.err{color:#c92a2a;text-align:center;padding:30px}
</style>
</head>
<body>
<div class="wrap" id="app"><div class="msg" id="boot">Chargement…</div></div>
<div class="bar" id="bar" style="display:none">
<div class="inner"><div class="tot"><span id="cntlab">0 acte</span><b id="tot">0,00 $</b></div><button class="go" id="go" disabled>Confirmer</button></div>
</div>
<script>
(function(){
var t = new URLSearchParams(location.search).get('t') || '';
var app = document.getElementById('app'), bar = document.getElementById('bar');
var data = null, acts = {}, photos = [], ctrls = {}, byCode = {};
function api(p,o){ return fetch(p,o).then(function(r){ return r.json().then(function(j){ if(!r.ok||j.ok===false) throw new Error(j.error||('HTTP '+r.status)); return j; }); }); }
function esc(s){ return String(s==null?'':s).replace(/[&<>]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;'}[c];}); }
function money(n){ return (Math.round(n*100)/100).toFixed(2).replace('.',',')+' $'; }
function amountOf(){ var a=0; (data.rates||[]).forEach(function(r){ var v=acts[r.code]; if(r.unit==='case'){ if(v===true) a+=+r.price||0; } else { var q=+v||0; if(q>0) a+=(+r.price||0)*q; } }); return a; }
function recount(){ var n=0; for(var k in acts){ if(k[0]==='_') continue; var v=acts[k]; if(v===true||+v>0) n++; } var c=document.getElementById('cntlab'); if(c) c.textContent=n+' acte'+(n>1?'s':''); var tt=document.getElementById('tot'); if(tt) tt.textContent=money(amountOf()); }
function commonPrefix(arr){ if(!arr.length) return ''; var f=arr[0], n=f.length; arr.forEach(function(s){ var i=0; while(i<n && i<s.length && s[i]===f[i]) i++; n=i; }); return f.slice(0,n); }
function applyImplies(code){ var r=byCode[code]; if(r&&r.implies&&ctrls[r.implies]&&acts[r.implies]!==true) ctrls[r.implies](true); }
function noteVal(){ var n=document.getElementById('noteIn'); return n && n.value ? n.value.trim().slice(0,1000) : ''; }
api('/acte/data?t='+encodeURIComponent(t)).then(function(d){ data=d; render(); })
.catch(function(e){ app.innerHTML='<div class="err">Lien invalide ou expiré.<br><small>'+esc(e.message)+'</small></div>'; });
function render(){
var defaults=data.defaults||[], rates=data.rates||[], GROUPS=data.groups||{};
rates.forEach(function(r){ byCode[r.code]=r; });
var h='<header><div class="tk">Ticket #'+esc(data.ticket)+'</div><h1>'+esc(data.label||('Ticket #'+data.ticket))+'</h1>';
var meta=[data.customer,data.address].filter(Boolean).join(' · '); if(meta) h+='<div class="meta">'+esc(meta)+'</div>';
if(data.subcontractor) h+='<div class="meta">Sous-traitant : '+esc(data.subcontractor)+'</div>';
h+='</header><div class="card" id="acts"></div>';
h+='<div class="sec">Photos</div><div class="card ph"><button type="button" class="addph" id="addph">📷 Ajouter une photo</button><input type="file" id="phin" accept="image/*" capture="environment" multiple style="display:none"><div class="thumbs" id="thumbs"></div></div>';
h+='<div class="sec">Note</div><div class="card"><button type="button" class="addph" id="noteBtn">📝 Ajouter une note / des détails (optionnel)</button><div id="noteWrap" style="display:none;padding:12px"><textarea id="noteIn" rows="3" placeholder="Précisions sur le travail, particularités, problème rencontré…"></textarea></div></div>';
var entLbl=data.entente?'l<a href="'+esc(data.entente)+'" target="_blank" rel="noopener" style="text-decoration:underline">entente de services</a>':'lentente de services';
h+='<div class="cert"><input type="checkbox" id="cert"><label for="cert">Je certifie que mon travail est complété et assujetti aux termes de '+entLbl+'.</label></div>';
h+='<button type="button" class="infruct" id="infruct">⚠ Visite infructueuse (client absent — à replanifier)</button>';
app.innerHTML=h;
var box=document.getElementById('acts');
var groups={}, order=[];
rates.forEach(function(r){ if(r.grp){ if(!groups[r.grp]){ groups[r.grp]=[]; order.push({g:r.grp}); } groups[r.grp].push(r); } else order.push({r:r}); });
order.forEach(function(o){ if(o.g){ var g=GROUPS[o.g]||{kind:'seg'}; (g.kind==='count'?renderCount:renderSeg)(box,o.g,groups[o.g],g); } else renderStd(box,o.r,defaults); });
bar.style.display='block'; recount();
// photos
var phin=document.getElementById('phin');
document.getElementById('addph').onclick=function(){ phin.click(); };
var noteBtn=document.getElementById('noteBtn'); noteBtn.onclick=function(){ document.getElementById('noteWrap').style.display='block'; noteBtn.style.display='none'; document.getElementById('noteIn').focus(); };
phin.onchange=function(){ var files=Array.prototype.slice.call(phin.files); phin.value=''; getGeo(function(geo){ files.forEach(function(f){ if(/^image\//.test(f.type)) compress(f,function(durl){ upPhoto(durl,geo); }); }); }); };
// cert + confirmer
var go=document.getElementById('go'), cert=document.getElementById('cert');
cert.onchange=function(){ go.disabled=!cert.checked; };
go.onclick=function(){ submit({acts:acts,certified:true,photos:photos,notes:noteVal()}, 'Soumis — merci !'); };
document.getElementById('infruct').onclick=function(){
var why=prompt('Visite infructueuse — raison ? (ex. client absent, adresse introuvable, reporté)'); if(!why) return;
submit({outcome:'infructueuse',reason:why,photos:photos,notes:noteVal()}, 'Visite signalée — le ticket reste à replanifier.');
};
}
function submit(payload, okMsg){
var go=document.getElementById('go'); if(go){ go.disabled=true; go.textContent='Envoi…'; }
payload.t=t;
api('/acte/submit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})
.then(function(j){ bar.style.display='none'; app.innerHTML='<div class="msg done card"><div class="big">'+(payload.outcome?'⚠️':'✅')+'</div><h1>'+esc(okMsg)+'</h1><div class="meta">Référence '+esc(j.ref)+'</div><p class="meta">Ticket #'+esc(data.ticket)+'. Tu peux fermer cette page.</p></div>'; })
.catch(function(e){ if(go){ go.disabled=false; go.textContent='Confirmer'; } alert('Erreur : '+e.message); });
}
// ── photos : downscale ~1600px puis upload → /acte/photo ──
function compress(file, cb){
var rd=new FileReader();
rd.onload=function(){ var img=new Image(); img.onload=function(){ var max=1600,w=img.width,h=img.height; if(w>max||h>max){ if(w>h){h=Math.round(h*max/w);w=max;}else{w=Math.round(w*max/h);h=max;} } var cv=document.createElement('canvas'); cv.width=w; cv.height=h; cv.getContext('2d').drawImage(img,0,0,w,h); cb(cv.toDataURL('image/jpeg',0.82)); }; img.onerror=function(){ cb(rd.result); }; img.src=rd.result; };
rd.readAsDataURL(file);
}
// position de l'appareil au moment de la photo (best-effort ; refus/erreur → null, la photo s'envoie quand même)
function getGeo(cb){ if(!navigator.geolocation){ cb(null); return; } navigator.geolocation.getCurrentPosition(function(p){ cb({lat:+p.coords.latitude.toFixed(6),lon:+p.coords.longitude.toFixed(6),acc:Math.round(p.coords.accuracy||0)}); }, function(){ cb(null); }, { enableHighAccuracy:true, timeout:8000, maximumAge:30000 }); }
function upPhoto(dataUrl, geo){
var thumbs=document.getElementById('thumbs');
var el=document.createElement('div'); el.className='thumb'; el.innerHTML='<img src="'+dataUrl+'">'+(geo?'<i class="geo" title="position enregistrée">📍</i>':'')+'<b>…</b>'; thumbs.appendChild(el);
api('/acte/photo',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({t:t,image:dataUrl})})
.then(function(j){ var rec={file:j.file}; if(geo){ rec.lat=geo.lat; rec.lon=geo.lon; rec.acc=geo.acc; } photos.push(rec); var x=el.querySelector('b'); x.textContent='×'; x.onclick=function(){ var i=photos.indexOf(rec); if(i>=0)photos.splice(i,1); el.remove(); }; })
.catch(function(e){ el.querySelector('b').textContent='✗'; setTimeout(function(){el.remove();},1500); alert('Photo: '+e.message); });
}
function stepper(code){
var wrap=document.createElement('div'); wrap.className='step';
var minus=document.createElement('button'); minus.type='button'; minus.textContent=''; minus.tabIndex=-1;
var inp=document.createElement('input'); inp.type='number'; inp.inputMode='numeric'; inp.min='0'; inp.placeholder='0';
var plus=document.createElement('button'); plus.type='button'; plus.textContent='+'; plus.tabIndex=-1;
function set(v){ v=Math.max(0,v||0); inp.value=v?String(v):''; acts[code]=v; recount(); }
minus.onclick=function(){ set((+inp.value||0)-1); }; plus.onclick=function(){ set((+inp.value||0)+1); };
inp.oninput=function(){ acts[code]=+inp.value||0; recount(); };
wrap.appendChild(minus); wrap.appendChild(inp); wrap.appendChild(plus);
return { el:wrap, input:inp, set:set };
}
function renderStd(box,r,defaults){
var row=document.createElement('div'); row.className='row';
var lab=document.createElement('div'); lab.className='lab'; lab.innerHTML=esc(r.label); row.appendChild(lab);
if(r.unit==='case'){
var on=defaults.indexOf(r.code)>=0; if(on) acts[r.code]=true;
var tg=document.createElement('label'); tg.className='tg';
var cb=document.createElement('input'); cb.type='checkbox'; cb.checked=on; cb.setAttribute('aria-label',r.label);
var tr=document.createElement('span'); tr.className='tr'; tg.appendChild(cb); tg.appendChild(tr);
ctrls[r.code]=function(v){ cb.checked=!!v; if(v)acts[r.code]=true; else delete acts[r.code]; recount(); };
cb.onchange=function(){ var nv=cb.checked; if(nv)acts[r.code]=true; else delete acts[r.code]; recount(); if(nv) applyImplies(r.code); };
row.appendChild(tg);
} else {
var unit=r.unit==='metres'?'m':(r.unit==='heures'?'h':'qté');
var s=stepper(r.code); row.appendChild(s.el);
var u=document.createElement('span'); u.className='unit'; u.textContent=unit; row.appendChild(u);
}
box.appendChild(row);
}
function renderSeg(box,grp,opts,g){
var row=document.createElement('div'); row.className='row grp';
var pref=commonPrefix(opts.map(function(o){return o.label;}));
var lab=document.createElement('div'); lab.className='lab'; lab.innerHTML=esc(g.label||pref.trim()||grp); row.appendChild(lab);
var seg=document.createElement('div'); seg.className='seg'; row.appendChild(seg);
var rev=document.createElement('div'); rev.className='rev'; rev.style.display='none'; row.appendChild(rev);
var metresStep=null, metresCode=null;
opts.forEach(function(o){
var segTxt=(o.label.indexOf(pref)===0?o.label.slice(pref.length):o.label).trim()||o.label;
if(o.unit==='metres'){ segTxt=segTxt.replace(/\s*\(au m.tre\)\s*/i,'').trim(); metresCode=o.code; }
var b=document.createElement('button'); b.type='button'; b.className='opt'; b.textContent=segTxt;
b.onclick=function(){
var already=b.classList.contains('on');
Array.prototype.forEach.call(seg.children,function(x){ x.classList.remove('on'); });
opts.forEach(function(oo){ delete acts[oo.code]; });
rev.style.display='none';
if(!already){ b.classList.add('on'); if(o.unit==='metres'){ rev.style.display='flex'; if(metresStep){ metresStep.set(+metresStep.input.value||0); metresStep.input.focus(); } } else { acts[o.code]=true; applyImplies(o.code); } }
recount();
};
seg.appendChild(b);
});
if(metresCode){ var cap=document.createElement('span'); cap.className='cap'; cap.textContent='Longueur :'; rev.appendChild(cap); metresStep=stepper(metresCode); rev.appendChild(metresStep.el); var u=document.createElement('span'); u.className='unit'; u.textContent='m'; rev.appendChild(u); }
box.appendChild(row);
}
function renderCount(box,grp,members,g){
var first=members.filter(function(m){return m.unit==='case';})[0], add=members.filter(function(m){return m.unit!=='case';})[0];
var max=g.max||6;
var row=document.createElement('div'); row.className='row grp';
var lab=document.createElement('div'); lab.className='lab'; lab.innerHTML=esc(g.label||grp); row.appendChild(lab);
var seg=document.createElement('div'); seg.className='seg count'; row.appendChild(seg);
var btns=[];
function pick(n){ btns.forEach(function(b,i){ b.classList.toggle('on',i===n); }); if(first){ if(n>=1)acts[first.code]=true; else delete acts[first.code]; } if(add){ if(n>=2)acts[add.code]=n-1; else delete acts[add.code]; } recount(); }
for(var n=0;n<=max;n++){ (function(n){ var b=document.createElement('button'); b.type='button'; b.className='opt'; b.textContent=String(n); b.onclick=function(){ pick(n); }; seg.appendChild(b); btns.push(b); })(n); }
pick(0);
box.appendChild(row);
}
})();
</script>
</body>
</html>