feat(ops+hub): notify techs of their tournée (SMS/email + token link + flags)

À la publication (vue Tournées), bouton « Notifier » → aperçu par tech
PUIS envoi. Chaque tech reçoit :
- UN lien token personnel (techFieldLink = app terrain, ses billets du
  jour à répondre),
- la liste de ses interventions avec ADRESSE + flags cruciaux (AM/PM ·
  URGENT · Appeler avant) détectés du titre/notes/priorité.

Hub (lib/roster.js) : composeDispatchNotifs(dates, techIds) + route
POST /roster/notify-dispatch (preview=compose sans envoi | envoi SMS via
twilio + Email via lib/email.js, best-effort par tech, retourne compte +
erreurs). jobFlags() détecte AM/PM/urgent/appeler-avant.
Client : api roster.notifyDispatch ; dialogue APERÇU (toggles SMS/Courriel
+ liste par tech + corps du message, alerte si pas de tel/courriel) →
« Envoyer ». Aperçu d'abord = pas d'envoi accidentel.

Vérifié : hub node --check OK ; client rend le bouton + dialogue + toggles,
0 erreur. ⚠️ Le PREVIEW/envoi nécessite le déploiement HUB (nouvelle
route) — GATÉ + sortant. Faire un test 1 tech avant usage réel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-06 09:24:21 -04:00
parent add3c08cee
commit 7da4b1289c
3 changed files with 109 additions and 0 deletions

View File

@ -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 ──

View File

@ -484,6 +484,7 @@
<span class="text-caption text-grey-7">Journée :</span>
<OccupancyStrip :days="routeStripDays" :selected="routesDay" style="flex:1 1 100%;min-width:0" @select="routesDay = $event" />
<q-space />
<q-btn dense unelevated no-caps size="sm" color="deep-purple-6" text-color="white" icon="forward_to_inbox" label="Notifier" :disable="!dayRoutes.length" @click="openNotifyDlg"><q-tooltip>Envoyer à chaque tech (SMS/courriel) le lien de sa tournée + les infos clés (adresse · AM/PM · urgent). Aperçu avant envoi.</q-tooltip></q-btn>
<q-btn dense unelevated no-caps size="sm" :color="showLivePos ? 'teal-6' : 'grey-4'" :text-color="showLivePos ? 'white' : 'grey-8'" icon="my_location" :label="'GPS live' + (dayLivePositions.length ? ' (' + dayLivePositions.length + ')' : '')" @click="showLivePos = !showLivePos"><q-tooltip>Positions GPS des techs (Traccar), rafraîchies ~25 s. Cliquer pour {{ showLivePos ? 'masquer' : 'afficher' }}.</q-tooltip></q-btn>
<span class="text-caption text-grey-6">{{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail</span>
</div>
@ -1357,6 +1358,41 @@
</q-dialog>
<!-- Volet DÉTAILS (double-clic sur un job du board) : grand panneau DROIT, pleine hauteur, ~pleine largeur billet + commentaires -->
<!-- Notifier les techs : aperçu par tech (lien token + billets + flags) puis envoi SMS/courriel -->
<q-dialog v-model="notifyDlg.open" :maximized="$q.screen.lt.sm">
<q-card :style="$q.screen.lt.sm ? 'width:100%;max-width:100%;height:100%' : 'width:560px;max-width:96vw'" class="column no-wrap">
<q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid #e2e8f0">
<q-icon name="forward_to_inbox" color="deep-purple-6" size="22px" class="q-mr-sm" />
<div class="col" style="min-width:0"><div class="text-subtitle1 text-weight-bold">Notifier les techniciens</div><div class="text-caption text-grey-7">Lien personnel de la tournée + adresse & flags (AM/PM · urgent · appeler avant)</div></div>
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="row items-center q-gutter-lg q-py-sm">
<q-toggle v-model="notifyDlg.sms" dense icon="sms" label="SMS" color="teal-6" />
<q-toggle v-model="notifyDlg.email" dense icon="email" label="Courriel" color="purple-6" />
</q-card-section>
<q-separator />
<q-card-section class="col scroll q-pa-none" style="overflow:auto">
<div v-if="notifyDlg.loading" class="text-center q-pa-lg"><q-spinner size="28px" color="primary" /><div class="text-caption text-grey-6 q-mt-sm">Préparation des messages</div></div>
<div v-else-if="!notifyDlg.techs.length" class="text-center text-grey-6 q-pa-lg">Aucun technicien avec une tournée à notifier ce jour.</div>
<q-list v-else separator>
<q-expansion-item v-for="m in notifyDlg.techs" :key="m.techId" dense>
<template #header>
<q-item-section avatar><q-avatar size="30px" color="deep-purple-1" text-color="deep-purple-9" style="font-weight:700;font-size:11px">{{ initials(m.name) }}</q-avatar></q-item-section>
<q-item-section><q-item-label>{{ m.name }}</q-item-label><q-item-label caption>{{ m.jobCount }} intervention(s)<span v-if="notifyDlg.sms && !m.phone" class="text-negative"> · aucun téléphone</span><span v-if="notifyDlg.email && !m.email" class="text-orange-8"> · aucun courriel</span></q-item-label></q-item-section>
</template>
<q-card flat><q-card-section class="q-pt-none"><pre class="notify-preview">{{ m.sms }}</pre></q-card-section></q-card>
</q-expansion-item>
</q-list>
</q-card-section>
<q-separator />
<q-card-actions align="right" class="q-pa-md">
<div class="text-caption text-grey-6 col text-left" style="min-width:0">Aperçu rien n'est envoyé avant « Envoyer ».</div>
<q-btn flat no-caps label="Annuler" v-close-popup />
<q-btn unelevated no-caps color="deep-purple-6" icon="send" :label="'Envoyer (' + notifyDlg.techs.length + ')'" :loading="notifyDlg.sending" :disable="!notifyDlg.techs.length || (!notifyDlg.sms && !notifyDlg.email)" @click="sendNotify" />
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="jobDetail.open" position="right" full-height>
<q-card class="jd-card">
<q-card-section class="row items-center q-pb-sm">
@ -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; }

View File

@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 = `<div style="font-family:Arial,sans-serif;font-size:14px;color:#1e293b"><p>Bonjour ${_esc(t.name)},</p><p>Votre tournée du <b>${_esc(dLbl)}</b> — ${jobs.length} intervention(s) :</p><ul style="padding-left:18px">` + jobs.map(j => { const l = line(j); return `<li style="margin:4px 0">${l.tm ? '<b>' + _esc(l.tm) + '</b>' : ''}${_esc(l.loc)}${l.flags.length ? ' <b style="color:#c0392b">[' + _esc(l.flags.join(' · ')) + ']</b>' : ''}</li>` }).join('') + `</ul><p style="margin-top:16px"><a href="${link}" style="background:#16a34a;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none;font-weight:600">Ouvrir mes billets</a></p><p style="color:#94a3b8;font-size:12px">Lien personnel — ne pas partager.</p></div>`
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') {