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>
221 lines
18 KiB
HTML
221 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">
|
||
<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 {'&':'&','<':'<','>':'>'}[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>':'l’entente 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>
|