planif Notifier : toggles SMS/courriel PAR TECH (SMS préféré) + édition inline du contact

- 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 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-07 14:04:11 -04:00
parent d6db1a5fa4
commit 0ba789a5c0
3 changed files with 57 additions and 14 deletions

View File

@ -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 })

View File

@ -1401,10 +1401,7 @@
<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-card-section class="q-py-sm text-caption text-grey-7"><q-icon name="info" size="14px" /> Coche le canal <b>par technicien</b> <b>SMS préféré</b>. Décoche pour exclure quelqu'un (ex. envoi de test).</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>
@ -1413,9 +1410,23 @@
<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>
<q-item-section><q-item-label>{{ m.name }}</q-item-label><q-item-label caption>{{ m.jobCount }} intervention(s)<span v-if="!m.phone && !m.email" class="text-negative"> · aucune coordonnée</span></q-item-label></q-item-section>
<q-item-section side @click.stop>
<div class="row items-center q-gutter-xs no-wrap">
<q-toggle v-model="m.wantSms" dense size="sm" icon="sms" color="teal-6" :disable="!m.phone"><q-tooltip>{{ m.phone ? 'SMS → ' + m.phone : 'Aucun téléphone (ajoute-le ci-dessous)' }}</q-tooltip></q-toggle>
<q-toggle v-model="m.wantEmail" dense size="sm" icon="email" color="purple-6" :disable="!m.email"><q-tooltip>{{ m.email ? 'Courriel → ' + m.email : 'Aucun courriel (ajoute-le ci-dessous)' }}</q-tooltip></q-toggle>
</div>
</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-card flat><q-card-section class="q-pt-none">
<!-- Édition INLINE si coordonnée manquante sans quitter le dialogue -->
<div v-if="!m.phone || !m.email" class="row items-center q-gutter-sm q-mb-sm">
<q-input v-if="!m.phone" v-model="m.newPhone" dense outlined type="tel" placeholder="Ajouter un téléphone…" style="max-width:190px" @keyup.enter="saveNotifyContact(m)"><template #prepend><q-icon name="sms" size="16px" /></template></q-input>
<q-input v-if="!m.email" v-model="m.newEmail" dense outlined type="email" placeholder="Ajouter un courriel…" style="max-width:220px" @keyup.enter="saveNotifyContact(m)"><template #prepend><q-icon name="email" size="16px" /></template></q-input>
<q-btn dense unelevated size="sm" color="primary" icon="save" label="Enregistrer" no-caps :loading="m.saving" :disable="!m.newPhone && !m.newEmail" @click="saveNotifyContact(m)" />
</div>
<pre class="notify-preview">{{ m.sms }}</pre>
</q-card-section></q-card>
</q-expansion-item>
</q-list>
</q-card-section>
@ -1423,7 +1434,7 @@
<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-btn unelevated no-caps color="deep-purple-6" icon="send" :label="'Envoyer (' + notifyRecipientCount + ')'" :loading="notifyDlg.sending" :disable="!notifyRecipientCount" @click="sendNotify" />
</q-card-actions>
</q-card>
</q-dialog>
@ -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 lenregistrement' })
} 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 })

View File

@ -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' })