app terrain : commentaire NATIF (reste sur OPS) au lieu du renvoi vers store.targo.ca

- field-app : « 💬 Répondre » (lien legacy store.targo.ca) → « 💬 Commenter » = boîte native (textarea + case
  « envoyer au client (public) ») qui POST /field/comment. Retrait du lien reply legacy.
- Hub /field/comment (token tech) : ajoute au FIL du ticket. Par défaut = NOTE INTERNE (jamais au client) ;
  case public cochée → réponse publique (courriel client) via postTicketLegacy(public). Job natif → Comment ERPNext.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-07 14:16:24 -04:00
parent 0ba789a5c0
commit eb8376c9c3
2 changed files with 34 additions and 2 deletions

View File

@ -933,6 +933,29 @@ async function handleFieldTech (req, res, method, path, url) {
const startTs = j.actual_start || (onSite ? ts : null)
return json(res, up.ok ? 200 : 500, { ok: !!up.ok, on_site: onSite, distance_m: dist, minutes: minutesBetween(startTs, onSite ? ts : j.actual_end), actual_start: patch.actual_start || j.actual_start || '', actual_end: patch.actual_end || j.actual_end || '' })
}
// COMMENTAIRE du tech depuis l'app terrain → fil du ticket. Reste sur OPS (plus de renvoi vers store.targo.ca).
// Par défaut = NOTE INTERNE (jamais envoyée au client). Envoyée au client SEULEMENT si public=true (coché).
if (path === '/field/comment' && method === 'POST') {
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
const text = String(b.text || '').trim(); if (!text) return json(res, 400, { ok: false, error: 'texte requis' })
const isPublic = !!b.public
let posted = false, sentToCustomer = false
try {
const j = await erp.get('Dispatch Job', name, { fields: ['legacy_ticket_id'] })
const lid = j && j.legacy_ticket_id
if (lid) {
// Fil legacy (osTicket) = ce que le volet job affiche. public=1 → réponse publique (courriel client) ; sinon note interne.
const msg = isPublic ? text : ('🔧 [Note tech — interne]\n' + text)
const r = await require('./legacy-dispatch-sync').postTicketLegacy(lid, msg, isPublic, '')
posted = !!(r && r.ok !== false); sentToCustomer = isPublic && posted
} else {
// Job natif (pas de ticket legacy) → Comment ERPNext sur le Dispatch Job.
await erp.create('Comment', { comment_type: 'Comment', reference_doctype: 'Dispatch Job', reference_name: name, content: (isPublic ? '📣 [Public] ' : '🔧 [Note tech] ') + text })
posted = true
}
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
return json(res, 200, { ok: posted, public: isPublic, sentToCustomer })
}
if ((path === '/field' || path === '/field/app') && method === 'GET') return serveFieldApp(res) // PWA technicien (carte + tickets + photos + scan)
if (path === '/field/tech' && method === 'GET') { // jobs du jour (+ demain) du tech, via token PAR TECH
const tech = techFieldVerify(token); if (!tech) return json(res, 403, { ok: false, error: 'lien invalide' })

View File

@ -48,10 +48,18 @@
<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=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">
<textarea id=cmtxt placeholder="Note / commentaire pour ce ticket…" style="width:100%;box-sizing:border-box;min-height:70px;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">Ajouter au ticket</button>
<button onclick="document.getElementById('cmtbox').style.display='none'" style="flex:0 0 90px;background:#475569">Annuler</button>
</div>
</div>
<input type=file id=photo accept=image/* capture=environment style=display:none onchange=sendPhoto(this)>
</div>
</div>
@ -75,6 +83,8 @@ const $=id=>document.getElementById(id); let JOBS=[],CUR=null,map=null,jobMk=nul
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 openComment(){var b=$('cmtbox');b.style.display=(b.style.display==='none'||!b.style.display)?'block':'none';if(b.style.display==='block')$('cmtxt').focus()}
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;$('cmtbox').style.display='none';toast(pub?(r.sentToCustomer?'✓ Ajouté au ticket + envoyé au client':'✓ Ajouté (client non joignable)'):'✓ Note interne ajoutée au ticket')}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(()=>({}));
@ -101,7 +111,6 @@ function openDetail(i){
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}