From 0ba789a5c01e9a512a8d13f602fa3abfe3d55186 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Tue, 7 Jul 2026 14:04:11 -0400 Subject: [PATCH] =?UTF-8?q?planif=20Notifier=20:=20toggles=20SMS/courriel?= =?UTF-8?q?=20PAR=20TECH=20(SMS=20pr=C3=A9f=C3=A9r=C3=A9)=20+=20=C3=A9diti?= =?UTF-8?q?on=20inline=20du=20contact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chaque tech a ses propres toggles SMS/Courriel (cochés selon l'info dispo, SMS préféré) → on peut exclure quelqu'un (ex. envoi test). Envoi scindé : 1 appel SMS (techs cochés+tél) + 1 appel courriel (cochés+email). Bouton « Envoyer (N) » = nb réel de destinataires. - Édition INLINE dans le dialogue : tech sans téléphone/courriel → champ + « Enregistrer » sans quitter (roster.setTechContact → POST /roster/technician/contact → update Dispatch Technician + invalide le cache). Co-Authored-By: Claude Opus 4.8 --- apps/ops/src/api/roster.js | 1 + apps/ops/src/pages/PlanificationPage.vue | 56 ++++++++++++++++++------ services/targo-hub/lib/roster.js | 14 ++++++ 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index 54fd1d7..acffdba 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -149,6 +149,7 @@ export const createShift = (s) => jpost('/roster/shift', s) export const setWeeklySchedule = (techId, schedule) => jpost('/roster/technician/' + encodeURIComponent(techId) + '/weekly-schedule', { schedule }) export const materializeShifts = (payload) => jpost('/roster/materialize-shifts', payload || {}) // ÉQUIPE d'un job (assistants en renfort) — le lead reste inchangé ; l'assistant épinglé voit un bloc hachuré dans son horaire. +export const setTechContact = (b) => jpost('/roster/technician/contact', b) // édition inline téléphone/courriel d'un tech export const getJobTeam = (job) => jget('/roster/job/team?job=' + encodeURIComponent(job)) export const addAssistant = (job, a) => jpost('/roster/job/team', { job, add: a }) export const removeAssistant = (job, techId) => jpost('/roster/job/team', { job, remove: techId }) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index aa19cbb..611afef 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -1401,10 +1401,7 @@
Notifier les techniciens
Lien personnel de la tournée + adresse & flags (AM/PM · urgent · appeler avant)
- - - - + Coche le canal par technicienSMS préféré. Décoche pour exclure quelqu'un (ex. envoi de test).
Préparation des messages…
@@ -1413,9 +1410,23 @@ -
{{ m.sms }}
+ + +
+ + + +
+
{{ m.sms }}
+
@@ -1423,7 +1434,7 @@
Aperçu — rien n'est envoyé avant « Envoyer ».
- +
@@ -2093,7 +2104,8 @@ const showShiftEditor = ref(false); const editTpls = ref([]) const showTeamEditor = ref(false); const editTechs = ref([]) const notifySms = ref(false) // ── Notifier les techs de leur tournée (SMS/Email + lien token) : aperçu d'abord (compose sans envoi), puis envoi. ── -const notifyDlg = reactive({ open: false, loading: false, sending: false, sms: true, email: false, techs: [] }) +const notifyDlg = reactive({ open: false, loading: false, sending: false, techs: [] }) +const notifyRecipientCount = computed(() => (notifyDlg.techs || []).filter(m => (m.wantSms && m.phone) || (m.wantEmail && m.email)).length) function notifyScope () { // jour(s) + techs concernés = vue Tournées : jour choisi + techs ayant une tournée ; sinon semaine + techs visibles const dates = boardView.value === 'routes' && routesDay.value ? [routesDay.value] : (dayList.value || []).map(d => d.iso) const techIds = boardView.value === 'routes' ? (allDayRoutes.value || []).map(r => r.id) : (visibleTechs.value || []).map(t => t.id) @@ -2103,20 +2115,36 @@ async function openNotifyDlg () { const { dates, techIds } = notifyScope() if (!dates.length || !techIds.length) { $q.notify({ type: 'warning', message: 'Aucune tournée à notifier (sélectionne un jour avec des jobs assignés).' }); return } Object.assign(notifyDlg, { open: true, loading: true, techs: [] }) - try { const r = await roster.notifyDispatch({ dates, tech_ids: techIds, preview: true }); notifyDlg.techs = (r && r.techs) || [] } + // Par tech : SMS PRÉFÉRÉ (coché si téléphone), courriel seulement si pas de téléphone. Toggle par personne → exclure certains (ex. test). + try { const r = await roster.notifyDispatch({ dates, tech_ids: techIds, preview: true }); notifyDlg.techs = ((r && r.techs) || []).map(m => ({ ...m, wantSms: !!m.phone, wantEmail: !m.phone && !!m.email, newPhone: '', newEmail: '', saving: false })) } catch (e) { err(e) } finally { notifyDlg.loading = false } } async function sendNotify () { - if (!notifyDlg.sms && !notifyDlg.email) { $q.notify({ type: 'warning', message: 'Choisis au moins un canal (SMS ou courriel).' }); return } - const { dates, techIds } = notifyScope() + const smsIds = notifyDlg.techs.filter(m => m.wantSms && m.phone).map(m => m.techId) + const emailIds = notifyDlg.techs.filter(m => m.wantEmail && m.email).map(m => m.techId) + if (!smsIds.length && !emailIds.length) { $q.notify({ type: 'warning', message: 'Aucun destinataire coché (SMS ou courriel sur au moins un tech).' }); return } + const { dates } = notifyScope() notifyDlg.sending = true try { - const r = await roster.notifyDispatch({ dates, tech_ids: techIds, sms: notifyDlg.sms, email: notifyDlg.email }) - $q.notify({ type: (r.errors && r.errors.length) ? 'warning' : 'positive', message: `Notifié : ${r.sms || 0} SMS · ${r.email || 0} courriel(s)` + ((r.errors && r.errors.length) ? ` · ${r.errors.length} échec(s)` : ''), timeout: 4000 }) - if (r.errors && r.errors.length) console.warn('notify errors', r.errors) + let sms = 0, email = 0; const errs = [] + if (smsIds.length) { const r = await roster.notifyDispatch({ dates, tech_ids: smsIds, sms: true, email: false }); sms = r.sms || 0; if (r.errors) errs.push(...r.errors) } + if (emailIds.length) { const r = await roster.notifyDispatch({ dates, tech_ids: emailIds, sms: false, email: true }); email = r.email || 0; if (r.errors) errs.push(...r.errors) } + $q.notify({ type: errs.length ? 'warning' : 'positive', message: `Notifié : ${sms} SMS · ${email} courriel(s)` + (errs.length ? ` · ${errs.length} échec(s)` : ''), timeout: 4000 }) + if (errs.length) console.warn('notify errors', errs) notifyDlg.open = false } catch (e) { err(e) } finally { notifyDlg.sending = false } } +// Édition INLINE du contact d'un tech sans coordonnées, sans quitter le dialogue (comme ailleurs dans l'app). +async function saveNotifyContact (m) { + const phone = String(m.newPhone || '').trim(); const email = String(m.newEmail || '').trim() + if (!phone && !email) return + m.saving = true + try { + const r = await roster.setTechContact({ tech_id: m.techId, phone: phone || undefined, email: email || undefined }) + if (r && r.ok) { if (phone) { m.phone = phone; m.wantSms = true } if (email) { m.email = email; if (!phone) m.wantEmail = true } m.newPhone = ''; m.newEmail = ''; $q.notify({ type: 'positive', message: 'Coordonnées enregistrées', timeout: 1800 }) } + else $q.notify({ type: 'negative', message: (r && r.error) || 'Échec de l’enregistrement' }) + } catch (e) { err(e) } finally { m.saving = false } +} const showLeave = ref(false); const leaveRows = ref([]); const leaveFilter = ref('Demandé') const newLeave = reactive({ technician: '', availability_type: 'Congé', from_date: '', to_date: '', reason: '', long_term: 0 }) const newTpl = reactive({ template_name: '', start: '08:00', end: '16:00', color: '#1976d2', on_call: 0 }) diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index ae6816c..fe41cf3 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -1591,6 +1591,20 @@ async function handle (req, res, method, path, url) { const r = await retryWrite(() => erp.update('Dispatch Job', job, { assistants: rows })) return json(res, r.ok ? 200 : 500, { ...r, job, assistants: rows.length, team: rows }) } + // Éditer le CONTACT d'un technicien (téléphone/courriel) — édition inline depuis Ops (ex. dialogue « Notifier »). + if (path === '/roster/technician/contact' && method === 'POST') { + const b = await parseBody(req) + const tid = String(b.tech_id || '').trim(); if (!tid) return json(res, 400, { error: 'tech_id requis' }) + let name = tid + try { const r = await erp.list('Dispatch Technician', { filters: [['technician_id', '=', tid]], fields: ['name'], limit: 1 }); if (r && r[0]) name = r[0].name } catch (e) {} + const patch = {} + if (b.phone != null && String(b.phone).trim()) patch.phone = String(b.phone).trim().slice(0, 40) + if (b.email != null && String(b.email).trim()) patch.email = String(b.email).trim().slice(0, 140) + if (!Object.keys(patch).length) return json(res, 400, { error: 'phone ou email requis' }) + const r = await retryWrite(() => erp.update('Dispatch Technician', name, patch)) + if (r && r.ok) invalidateTechCache() // le prochain fetchTechnicians relira le contact à jour + return json(res, r.ok ? 200 : 500, { ...r, tech: name, ...patch }) + } // Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis via le sélecteur Mapbox Ops. if (path === '/roster/job/set-location' && method === 'POST') { const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })