diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index 76fb20b..f2611ba 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -46,6 +46,8 @@ export const setAbsence = (tech, date, type, remove) => jpost('/roster/absence/s export const generate = (start, days = 7, weights) => jpost('/roster/generate', { start, days, weights }) export const publish = (assignments) => jpost('/roster/publish', { assignments }) export const publishWeek = (start, days, assignments, notify) => jpost('/roster/publish-week', { start, days, assignments, notify }) +// Notifier les techs de leur tournée (SMS/Email, lien token + infos) : preview=true → compose sans envoyer. +export const notifyDispatch = (payload) => jpost('/roster/notify-dispatch', payload) export const updateTemplate = (name, patch) => jput('/roster/template/' + encodeURIComponent(name), patch) // ── Copilote (Gemini Flash) + politique de reprise ── diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index e10efd5..f5a1ce0 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -484,6 +484,7 @@ Journée : + Envoyer à chaque tech (SMS/courriel) le lien de sa tournée + les infos clés (adresse · AM/PM · urgent). Aperçu avant envoi. Positions GPS des techs (Traccar), rafraîchies ~25 s. Cliquer pour {{ showLivePos ? 'masquer' : 'afficher' }}. {{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail @@ -1357,6 +1358,41 @@ + + + + + +
Notifier les techniciens
Lien personnel de la tournée + adresse & flags (AM/PM · urgent · appeler avant)
+ +
+ + + + + + +
Préparation des messages…
+
Aucun technicien avec une tournée à notifier ce jour.
+ + + +
{{ m.sms }}
+
+
+
+ + +
Aperçu — rien n'est envoyé avant « Envoyer ».
+ + +
+
+
+ @@ -1984,6 +2020,31 @@ const hiddenCount = computed(() => techs.value.filter(t => isHidden(t.id)).lengt 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: [] }) +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) + return { dates, techIds: [...new Set(techIds)] } +} +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) || [] } + 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() + 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) + notifyDlg.open = false + } catch (e) { err(e) } finally { notifyDlg.sending = 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 }) @@ -5925,6 +5986,7 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return .suggest-grp-x { opacity: .55; margin-left: 1px; } .suggest-grp-x:hover { opacity: 1; color: #dc2626; } .suggest-grp-clear { border-color: #cbd5e1; background: #f8fafc; color: #64748b; } +.notify-preview { white-space: pre-wrap; word-break: break-word; font-family: inherit; font-size: 11.5px; line-height: 1.45; color: #475569; margin: 0; background: #f8fafc; border-radius: 6px; padding: 8px; } .suggest-step { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; border-radius: 50%; background: #e0e7ff; color: #4338ca; font-size: 10px; font-weight: 700; margin-right: 2px; flex-shrink: 0; } .suggest-mlvl { font-size: 10px; color: #b45309; font-weight: 700; white-space: nowrap; margin-right: 3px; } .assign-foot { border-top: 1px solid #e0e0e0; padding: 6px 9px; font-size: 11px; color: #555; line-height: 1.45; background: #fafafa; border-radius: 0 0 8px 8px; } diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index b67b975..3646037 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -989,6 +989,35 @@ async function handleFieldTech (req, res, method, path, url) { function fieldLink (name) { return (cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca') + '/field?t=' + fieldSign(name) } function techFieldLink (techName) { return (cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca') + '/field?t=' + techFieldSign(techName) } +// ── Notifier les techs de leur tournée (SMS/Email) : un LIEN token par tech (app terrain = ses billets du jour) + +// les infos cruciales par intervention (adresse + AM/PM · URGENT · Appeler avant). Aperçu (preview) OU envoi. ── +function _esc (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])) } +function jobFlags (j) { + const t = ((j.subject || '') + ' ' + (j.notes || '')).toLowerCase(); const prio = String(j.priority || '').toLowerCase(); const f = [] + if (prio === 'urgent' || prio === 'high' || /\burgent\b/.test(t)) f.push('URGENT') + if (/\bpm\b|apr[eè]s.?midi/.test(t)) f.push('PM'); else if (/\bam\b|avant.?midi|\bmatin\b/.test(t)) f.push('AM') + if (/appeler? avant|appel avant|t[ée]l[ée]phoner avant|call before/.test(t)) f.push('Appeler avant') + return f +} +async function composeDispatchNotifs (dates, techIds) { + const techs = await fetchTechnicians(); const techById = {}; for (const t of techs) techById[t.id] = t + const emailById = {} + try { const er = await erp.list('Dispatch Technician', { filters: [['resource_type', '=', 'human']], fields: ['technician_id', 'name', 'email'], limit: 500 }); for (const r of er) emailById[r.technician_id || r.name] = r.email || '' } catch (e) {} + const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', 'in', techIds], ['scheduled_date', 'in', dates]], fields: ['name', 'assigned_tech', 'subject', 'customer_name', 'address', 'scheduled_date', 'start_time', 'priority', 'required_skill', 'notes', 'legacy_ticket_id'], orderBy: 'scheduled_date asc, start_time asc', limit: 1000 }) + const byTech = {}; for (const j of (rows || [])) (byTech[j.assigned_tech] || (byTech[j.assigned_tech] = [])).push(j) + const out = [] + for (const tid of techIds) { + const jobs = byTech[tid] || []; if (!jobs.length) continue + const t = techById[tid] || { name: tid }; const link = techFieldLink(tid) + const dLbl = [...new Set(jobs.map(j => j.scheduled_date))].join(', ') + const line = (j) => { const f = jobFlags(j); const tm = j.start_time ? String(j.start_time).slice(0, 5) + ' ' : ''; const loc = j.address || j.customer_name || j.subject || j.name; return { tm, loc, flags: f } } + const sms = `Targo — votre tournée ${dLbl} (${jobs.length}):\n` + jobs.map(j => { const l = line(j); return '• ' + l.tm + l.loc + (l.flags.length ? ' [' + l.flags.join(' · ') + ']' : '') }).join('\n') + `\nVos billets : ${link}` + const emailHtml = `

Bonjour ${_esc(t.name)},

Votre tournée du ${_esc(dLbl)} — ${jobs.length} intervention(s) :

    ` + jobs.map(j => { const l = line(j); return `
  • ${l.tm ? '' + _esc(l.tm) + '' : ''}${_esc(l.loc)}${l.flags.length ? ' [' + _esc(l.flags.join(' · ')) + ']' : ''}
  • ` }).join('') + `

Ouvrir mes billets

Lien personnel — ne pas partager.

` + out.push({ techId: tid, name: t.name, phone: t.phone || '', email: emailById[tid] || '', jobCount: jobs.length, link, sms, emailSubject: `Targo — votre tournée du ${dLbl} (${jobs.length} intervention(s))`, emailHtml }) + } + return out +} + // Stats par jour : effectif (techs distincts), heures TRAVAILLÉES, tickets dispatch. // La garde (on_call) = mise en disponibilité → exclue des heures travaillées. async function statsByDay (start, days) { @@ -1677,6 +1706,22 @@ async function handle (req, res, method, path, url) { const b = await parseBody(req) return json(res, 200, await publish(b.assignments)) } + // Notifier les techs de leur tournée : preview (compose sans envoi) OU envoi SMS/Email avec lien token + infos. + if (path === '/roster/notify-dispatch' && method === 'POST') { + const b = await parseBody(req) + const dates = Array.isArray(b.dates) ? b.dates.filter(Boolean) : [] + const techIds = Array.isArray(b.tech_ids) ? b.tech_ids.filter(Boolean) : [] + if (!dates.length || !techIds.length) return json(res, 400, { ok: false, error: 'dates et tech_ids requis' }) + const msgs = await composeDispatchNotifs(dates, techIds) + if (b.preview) return json(res, 200, { ok: true, preview: true, techs: msgs.map(m => ({ techId: m.techId, name: m.name, phone: m.phone, email: m.email, jobCount: m.jobCount, link: m.link, sms: m.sms, emailSubject: m.emailSubject })) }) + const sendSms = require('./twilio').sendSmsInternal; const { sendEmail } = require('./email') + let sms = 0, email = 0; const errors = [] + for (const m of msgs) { + if (b.sms && m.phone) { try { await sendSms(m.phone, m.sms, null); sms++ } catch (e) { errors.push(m.name + ' SMS: ' + (e.message || e)) } } + if (b.email && m.email) { try { const ok = await sendEmail({ to: m.email, subject: m.emailSubject, html: m.emailHtml }); if (ok) email++; else errors.push(m.name + ' email: échec envoi') } catch (e) { errors.push(m.name + ' email: ' + (e.message || e)) } } + } + return json(res, 200, { ok: true, techs: msgs.length, sms, email, errors }) + } // Publier = réécrire la semaine (efface tout sur la période, recrée la grille). // Idempotent + anti-doublons (contrairement au diff par case). if (path === '/roster/publish-week' && method === 'POST') {