From 7371c1b446685a8860bf9abb3318e79767f05807 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Sun, 19 Jul 2026 17:04:14 -0400 Subject: [PATCH] =?UTF-8?q?improve(booking):=20/book=20libre-service=20?= =?UTF-8?q?=E2=80=94=20modifier=20ou=20annuler=20un=20RDV=20confirm=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- services/targo-hub/lib/roster.js | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 5543207..ec6825d 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -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='
Lien invalide ou expiré. Contactez-nous.
';return} - if(r.job&&r.job.scheduled){info.textContent='';c.innerHTML='
Votre rendez-vous est déjà confirmé :
'+dayLabel(r.job.scheduled)+'.
';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='
Votre rendez-vous est confirmé :
'+dayLabel(r.job.scheduled)+'
' + +'' + +'Annuler mon rendez-vous'; + 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 l’annulation'}}; + 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='
Votre rendez-vous a été annulé.
';el('re').onclick=load} + else c.innerHTML='
'+((r&&r.error)||'Annulation impossible — contactez-nous.')+'
'})} function renderFreeform(){var c=el('content'); var opt=''; var row=function(n){return '
'+n+'
'}; @@ -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)