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)