improve(booking): /book libre-service — modifier ou annuler un RDV confirmé

Avant : une fois confirmé, le lien /book était un cul-de-sac (« déjà confirmé »). Maintenant le client peut, depuis SON lien :
- « Modifier mon rendez-vous » → ré-ouvre le sélecteur (mêmes créneaux) → nouveau choix écrase l'ancien.
- « Annuler mon rendez-vous » (2 clics de sécurité) → POST /book/api/cancel : libère le créneau, repasse le job en
  « À reporter » (open, sans tech/heure) pour recontact ; le token reste valide (« Reprendre un rendez-vous »).
Réduit les appels au support pour reporter/annuler. Vérifié : les 3 états (confirmé→Modifier→sélecteur ; annuler 2 clics→
annulé→reprendre), 0 erreur console ; live à msg.gigafibre.ca/book, /book/api/cancel token-gated (404 sans token). Hub sain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-19 17:04:14 -04:00
parent 81af69e68c
commit 7371c1b446

View File

@ -994,7 +994,20 @@ function el(id){return document.getElementById(id)}
function load(){api('/book/api/options?token='+encodeURIComponent(token)).then(function(r){
var info=el('jobinfo'),c=el('content');
if(!r.ok){info.textContent='';c.innerHTML='<div class=err>Lien invalide ou expiré. Contactez-nous.</div>';return}
if(r.job&&r.job.scheduled){info.textContent='';c.innerHTML='<div class=ok>Votre rendez-vous est déjà confirmé :<br><b>'+dayLabel(r.job.scheduled)+'</b>.</div>';return}
if(r.job&&r.job.scheduled){
info.textContent='';
// Déjà confirmé → libre-service : MODIFIER (re-choisir un créneau) ou ANNULER (2 clics de sécurité).
c.innerHTML='<div class=ok>Votre rendez-vous est confirmé :<br><b>'+dayLabel(r.job.scheduled)+'</b></div>'
+'<button class=btn id=mod>Modifier mon rendez-vous</button>'
+'<a class=btn2 id=cancel style="border-color:#c62828;color:#c62828;cursor:pointer">Annuler mon rendez-vous</a>';
el('mod').onclick=function(){showPicker(r)};
el('cancel').onclick=function(){var b=el('cancel');if(b.dataset.c){doCancel()}else{b.dataset.c='1';b.textContent='Confirmer lannulation'}};
return
}
showPicker(r)
})}
function showPicker(r){
var c=el('content'),info=el('jobinfo');
dur=Number(r.job&&r.job.duration)||1;windows=r.windows||[];loc=(r.job&&r.job.location)||'';svc=(r.job&&r.job.service_type)||'';
info.textContent=(r.job&&r.job.service_type?r.job.service_type+' · ':'')+((r.job&&r.job.location)||'');
if(!windows.length){renderFreeform();return}
@ -1005,7 +1018,12 @@ function load(){api('/book/api/options?token='+encodeURIComponent(token)).then(f
el('seg').onclick=function(e){var b=e.target.closest('button');if(b)setMode(b.dataset.m)};
el('go').onclick=function(){mode==='pick'?confirmPick():submitPropose()};
setMode('pick')
})}
}
function doCancel(){var b=el('cancel');b.style.pointerEvents='none';b.textContent='Annulation…';
api('/book/api/cancel?token='+encodeURIComponent(token),{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(function(r){
var c=el('content');
if(r&&r.ok){c.innerHTML='<div class=ok>Votre rendez-vous a été annulé.</div><button class=btn id=re>Reprendre un rendez-vous</button>';el('re').onclick=load}
else c.innerHTML='<div class=err>'+((r&&r.error)||'Annulation impossible — contactez-nous.')+'</div>'})}
function renderFreeform(){var c=el('content');
var opt='<option value=09:00>Matin</option><option value=13:00>Après-midi</option><option value=17:00>Soir</option>';
var row=function(n){return '<div class=ff-row><span class=ff-n>'+n+'</span><input type=date class=ff-date data-n="'+n+'"><select class=ff-per data-n="'+n+'">'+opt+'</select></div>'};
@ -1074,6 +1092,13 @@ async function handlePublicBooking (req, res, method, path, url) {
addHold(key, minutes)
return json(res, 200, { ok: true, minutes, expires_in_s: minutes * 60 })
}
// Annulation LIBRE-SERVICE par le client (depuis son lien) : libère le créneau → retour « À reporter » (open, sans
// tech ni heure) pour que le répartiteur recontacte. Le booking_token reste valide (le client peut reprendre un RDV).
if (path === '/book/api/cancel' && method === 'POST') {
const job = await jobByToken(token); if (!job) return json(res, 404, { ok: false, error: 'lien invalide' })
const r = await retryWrite(() => erp.update('Dispatch Job', job.name, { booking_status: 'À reporter', scheduled_date: null, start_time: null, assigned_tech: null, status: 'open' }))
return json(res, r.ok ? 200 : 500, r.ok ? { ok: true, cancelled: true } : { ok: false, error: r.error || 'échec' })
}
if (path === '/book/api/submit' && method === 'POST') {
const job = await jobByToken(token); if (!job) return json(res, 404, { ok: false, error: 'lien invalide' })
const b = await parseBody(req); const dur = Number(job.duration_h) || 1; const skill = skillForJob(job)