feat(events): sélecteur d'audience (liste clients filtrée OU CSV) + aperçu + choix de modèle
Dialogue « Envoyer une invitation » → bascule Test / Envoi de masse. Mode masse :
- Choix du MODÈLE de courriel (défaut « Invitation Fête 20 ans (TARGO) » + modèles de
/campaigns/templates) — rend le module réutilisable.
- Audience par FILTRES liste clients (clients actifs / abonnement actif) OU IMPORT CSV
(email seul obligatoire ; firstname/lastname/language/customer_id/phone optionnels, alias FR/EN).
- Bouton « Aperçu de l'audience » → décompte live + échantillon (ex. 7943 clients actifs avec
courriel, 759 sans courriel écartés). Choix canal Mailjet (suivi)/Gmail.
Hub : resolveAudience({mode,filters,csv}) (recherche aveugle clients via erp.list + Service
Subscription pour « abonnement actif » ; dédup + courriel requis) + POST /events/:id/audience.
Filtre territoire retiré (champ ERPNext non peuplé → 0 résultat, éviter la confusion).
⚠️ L'ENVOI DE MASSE réel (création campagne + blast + suivi) reste GATÉ/non branché — bannière
« dernière étape à activer ». Test-à-soi inchangé. 29/29 tests unitaires. Déployé hub + SPA.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
632b466310
commit
1f74194419
|
|
@ -25,3 +25,5 @@ export async function listEvents () { return hubFetch('/events') }
|
||||||
export async function listRsvps (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}/rsvps`) }
|
export async function listRsvps (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}/rsvps`) }
|
||||||
export async function deleteRsvp (eventId, key) { return hubFetch(`/events/${encodeURIComponent(eventId)}/rsvps/${encodeURIComponent(key)}`, { method: 'DELETE' }) }
|
export async function deleteRsvp (eventId, key) { return hubFetch(`/events/${encodeURIComponent(eventId)}/rsvps/${encodeURIComponent(key)}`, { method: 'DELETE' }) }
|
||||||
export async function sendInvite (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/send`, { method: 'POST', body }) }
|
export async function sendInvite (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/send`, { method: 'POST', body }) }
|
||||||
|
export async function previewAudience (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience`, { method: 'POST', body }) }
|
||||||
|
export async function listTemplates () { return hubFetch('/campaigns/templates') }
|
||||||
|
|
|
||||||
|
|
@ -107,22 +107,67 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Dialogue d'envoi (test) -->
|
<!-- Dialogue d'envoi : test OU audience de masse (aperçu ; le blast reste à activer) -->
|
||||||
<q-dialog v-model="showSend">
|
<q-dialog v-model="showSend">
|
||||||
<q-card style="min-width:320px;max-width:440px">
|
<q-card style="min-width:340px;max-width:560px;width:100%">
|
||||||
<q-card-section class="row items-center q-pb-none">
|
<q-card-section class="row items-center q-pb-none">
|
||||||
<div class="text-h6">Envoyer une invitation</div><q-space />
|
<div class="text-h6">Envoyer une invitation</div><q-space />
|
||||||
<q-btn flat round dense icon="close" v-close-popup />
|
<q-btn flat round dense icon="close" v-close-popup />
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="q-gutter-sm">
|
|
||||||
<div class="text-caption text-grey-7">Envoi d'un <b>test</b> à votre adresse (le courriel contient un lien personnalisé). L'envoi de masse (choix de l'audience) reste à configurer — rien n'est envoyé aux clients ici.</div>
|
<q-card-section class="q-pb-none">
|
||||||
<q-btn-toggle v-model="sendChannel" spread no-caps dense class="q-mt-sm"
|
<q-btn-toggle v-model="sendMode" spread no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8"
|
||||||
:options="[{ label: 'Mailjet (suivi)', value: 'mailjet' }, { label: 'Gmail direct', value: 'gmail' }]" />
|
:options="[{ label: 'Test à moi', value: 'test' }, { label: 'Envoi de masse', value: 'mass' }]" />
|
||||||
<q-input v-model="testEmail" dense outlined type="email" label="Courriel de test" class="q-mt-sm" autofocus />
|
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- TEST -->
|
||||||
|
<q-card-section v-if="sendMode === 'test'" class="q-gutter-sm">
|
||||||
|
<div class="text-caption text-grey-7">Envoi d'un test (courriel avec lien personnalisé) à ton adresse.</div>
|
||||||
|
<q-btn-toggle v-model="sendChannel" spread no-caps dense :options="[{ label: 'Mailjet (suivi)', value: 'mailjet' }, { label: 'Gmail direct', value: 'gmail' }]" />
|
||||||
|
<q-input v-model="testEmail" dense outlined type="email" label="Courriel de test" autofocus />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- MASSE -->
|
||||||
|
<q-card-section v-else class="q-gutter-sm">
|
||||||
|
<q-select v-model="template" :options="templates" dense outlined emit-value map-options label="Modèle de courriel" />
|
||||||
|
<q-btn-toggle v-model="sendChannel" spread no-caps dense :options="[{ label: 'Mailjet (suivi)', value: 'mailjet' }, { label: 'Gmail direct', value: 'gmail' }]" />
|
||||||
|
<div class="text-caption text-grey-7"><q-icon name="info" size="14px" /> Mailjet = suivi ouvertures/clics. Gmail direct = aucun suivi.</div>
|
||||||
|
|
||||||
|
<div class="text-weight-medium text-grey-8 q-mt-sm">Audience</div>
|
||||||
|
<q-btn-toggle v-model="audienceSource" spread no-caps dense @update:model-value="audPreview = null"
|
||||||
|
:options="[{ label: 'Liste clients', value: 'list' }, { label: 'Import CSV', value: 'csv' }]" />
|
||||||
|
|
||||||
|
<div v-if="audienceSource === 'list'" class="q-gutter-sm">
|
||||||
|
<q-select v-model="audFilters.statut" :options="[{ label: 'Clients actifs', value: 'active' }, { label: 'Tous', value: '' }]" dense outlined emit-value map-options label="Statut" @update:model-value="audPreview = null" />
|
||||||
|
<q-toggle v-model="audFilters.active_sub" label="Abonnement actif seulement" @update:model-value="audPreview = null" />
|
||||||
|
<div class="text-caption text-grey-6">Astuce : « Clients actifs » = ~7900 clients avec courriel. « Abonnement actif » restreint à ceux ayant un service en cours.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="q-gutter-sm">
|
||||||
|
<q-file v-model="csvFile" dense outlined label="Fichier CSV" accept=".csv,text/csv" @update:model-value="onCsvFile">
|
||||||
|
<template #prepend><q-icon name="upload_file" /></template>
|
||||||
|
</q-file>
|
||||||
|
<div class="text-caption text-grey-6">Colonne obligatoire : <b>email</b>. Optionnelles : firstname, lastname, language (fr/en), customer_id, phone.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-btn outline no-caps color="primary" icon="groups" label="Aperçu de l'audience" :loading="previewing" @click="doPreview" />
|
||||||
|
|
||||||
|
<q-banner v-if="audPreview" dense rounded class="bg-blue-1 text-blue-10">
|
||||||
|
<b>{{ audPreview.count }}</b> destinataire(s)<span v-if="audPreview.dropped_no_email"> · {{ audPreview.dropped_no_email }} sans courriel écarté(s)</span>
|
||||||
|
<div v-if="audPreview.sample && audPreview.sample.length" class="text-caption q-mt-xs" style="opacity:.85">
|
||||||
|
ex. {{ audPreview.sample.slice(0, 4).map(s => s.firstname || s.email).join(', ') }}…
|
||||||
|
</div>
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<q-banner dense rounded class="bg-grey-2 text-grey-8">
|
||||||
|
<template #avatar><q-icon name="lock" color="grey-7" /></template>
|
||||||
|
L'envoi réel (création de la campagne + envoi suivi) est la dernière étape à activer — rien n'est envoyé aux clients ici.
|
||||||
|
</q-banner>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-actions align="right">
|
<q-card-actions align="right">
|
||||||
<q-btn flat no-caps label="Fermer" v-close-popup />
|
<q-btn flat no-caps label="Fermer" v-close-popup />
|
||||||
<q-btn unelevated no-caps color="primary" icon="send" label="Envoyer un test" :loading="sending" @click="sendTestNow" />
|
<q-btn v-if="sendMode === 'test'" unelevated no-caps color="primary" icon="send" label="Envoyer un test" :loading="sending" @click="sendTestNow" />
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
@ -139,7 +184,7 @@ import DynamicFilter from 'src/components/shared/DynamicFilter.vue'
|
||||||
import { formatDateTimeShort as fmtDate } from 'src/composables/useFormatters'
|
import { formatDateTimeShort as fmtDate } from 'src/composables/useFormatters'
|
||||||
import { useCsvExport } from 'src/composables/useCsvExport'
|
import { useCsvExport } from 'src/composables/useCsvExport'
|
||||||
import { usePermissions } from 'src/composables/usePermissions'
|
import { usePermissions } from 'src/composables/usePermissions'
|
||||||
import { listRsvps, deleteRsvp, sendInvite } from 'src/api/events'
|
import { listRsvps, deleteRsvp, sendInvite, previewAudience, listTemplates } from 'src/api/events'
|
||||||
|
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
const { exportCsv } = useCsvExport()
|
const { exportCsv } = useCsvExport()
|
||||||
|
|
@ -159,6 +204,16 @@ const showSend = ref(false)
|
||||||
const sendChannel = ref('mailjet')
|
const sendChannel = ref('mailjet')
|
||||||
const testEmail = ref('')
|
const testEmail = ref('')
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
|
// Envoi de masse (audience + modèle) — aperçu seulement ; le blast reste à activer.
|
||||||
|
const sendMode = ref('test')
|
||||||
|
const template = ref('')
|
||||||
|
const templates = ref([{ value: '', label: 'Invitation Fête 20 ans (TARGO)' }])
|
||||||
|
const audienceSource = ref('list')
|
||||||
|
const audFilters = ref({ statut: 'active', active_sub: false })
|
||||||
|
const csvFile = ref(null)
|
||||||
|
const audCsvText = ref('')
|
||||||
|
const audPreview = ref(null)
|
||||||
|
const previewing = ref(false)
|
||||||
|
|
||||||
const publicUrl = computed(() => ev.value.public_url || '')
|
const publicUrl = computed(() => ev.value.public_url || '')
|
||||||
|
|
||||||
|
|
@ -225,7 +280,28 @@ function confirmDelete (row) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepSend () { testEmail.value = testEmail.value || userEmail.value || ''; showSend.value = true }
|
function prepSend () { testEmail.value = testEmail.value || userEmail.value || ''; if (templates.value.length <= 1) loadTemplates(); showSend.value = true }
|
||||||
|
async function loadTemplates () {
|
||||||
|
try {
|
||||||
|
const d = await listTemplates()
|
||||||
|
const files = (d.templates || []).map(t => (typeof t === 'string' ? { value: t, label: t } : { value: t.name, label: t.label || t.name }))
|
||||||
|
templates.value = [{ value: '', label: 'Invitation Fête 20 ans (TARGO)' }, ...files]
|
||||||
|
} catch (e) { /* garde le défaut */ }
|
||||||
|
}
|
||||||
|
function onCsvFile (file) {
|
||||||
|
audPreview.value = null
|
||||||
|
if (!file) { audCsvText.value = ''; return }
|
||||||
|
const rd = new FileReader()
|
||||||
|
rd.onload = () => { audCsvText.value = String(rd.result || '') }
|
||||||
|
rd.readAsText(file)
|
||||||
|
}
|
||||||
|
async function doPreview () {
|
||||||
|
previewing.value = true; audPreview.value = null
|
||||||
|
try {
|
||||||
|
const body = audienceSource.value === 'csv' ? { mode: 'csv', csv: audCsvText.value } : { mode: 'filter', filters: audFilters.value }
|
||||||
|
audPreview.value = await previewAudience(EVENT_ID, body)
|
||||||
|
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { previewing.value = false }
|
||||||
|
}
|
||||||
async function sendTestNow () {
|
async function sendTestNow () {
|
||||||
const em = (testEmail.value || '').trim()
|
const em = (testEmail.value || '').trim()
|
||||||
if (!em) { $q.notify({ type: 'warning', message: 'Entrez un courriel de test' }); return }
|
if (!em) { $q.notify({ type: 'warning', message: 'Entrez un courriel de test' }); return }
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,76 @@ async function sendTest ({ eventId, emails, channel = 'mailjet', from = '', lang
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Audience de l'envoi de masse : liste clients filtrée OU import CSV ──────
|
||||||
|
function splitName (full) {
|
||||||
|
const parts = String(full || '').trim().split(/\s+/).filter(Boolean)
|
||||||
|
if (parts.length <= 1) return { firstname: parts[0] || '', lastname: '' }
|
||||||
|
return { firstname: parts[0], lastname: parts.slice(1).join(' ') }
|
||||||
|
}
|
||||||
|
const firstEmail = (s) => String(s || '').split(/[;,]/)[0].trim()
|
||||||
|
|
||||||
|
// CSV : SEULE colonne obligatoire = email ; alias FR/EN acceptés ; en-tête requise.
|
||||||
|
function parseAudienceCsv (text) {
|
||||||
|
const lines = String(text || '').split(/\r?\n/).filter(l => l.trim())
|
||||||
|
if (!lines.length) return { error: 'empty' }
|
||||||
|
const split = (l) => l.split(',').map(c => c.trim().replace(/^"|"$/g, ''))
|
||||||
|
const header = split(lines[0]).map(h => h.toLowerCase())
|
||||||
|
const idx = (al) => header.findIndex(h => al.includes(h))
|
||||||
|
const iEmail = idx(['email', 'courriel', 'e-mail', 'mail'])
|
||||||
|
if (iEmail < 0) return { error: 'no_email_column' }
|
||||||
|
const iFirst = idx(['firstname', 'first_name', 'prénom', 'prenom'])
|
||||||
|
const iLast = idx(['lastname', 'last_name', 'name', 'nom'])
|
||||||
|
const iLang = idx(['language', 'langue', 'lang'])
|
||||||
|
const iCust = idx(['customer_id', 'customer', 'client', 'no_client', 'numéro', 'numero'])
|
||||||
|
const iPhone = idx(['phone', 'téléphone', 'telephone', 'tel'])
|
||||||
|
const rows = []
|
||||||
|
for (let k = 1; k < lines.length; k++) {
|
||||||
|
const c = split(lines[k]); const email = firstEmail(c[iEmail] || '')
|
||||||
|
rows.push({ email, firstname: iFirst >= 0 ? c[iFirst] || '' : '', lastname: iLast >= 0 ? c[iLast] || '' : '', language: normLang(iLang >= 0 ? c[iLang] : 'fr'), customer_id: iCust >= 0 ? c[iCust] || '' : '', phone: iPhone >= 0 ? c[iPhone] || '' : '' })
|
||||||
|
}
|
||||||
|
return { rows }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Résout l'audience → destinataires [{email,firstname,lastname,language,customer_id}] (dédupliqués, courriel requis).
|
||||||
|
async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}) {
|
||||||
|
let rows = []
|
||||||
|
if (mode === 'csv') {
|
||||||
|
const p = parseAudienceCsv(csv)
|
||||||
|
if (p.error) return { error: p.error }
|
||||||
|
rows = p.rows
|
||||||
|
} else {
|
||||||
|
const erp = require('./erp')
|
||||||
|
const f = []
|
||||||
|
if (filters.statut === 'active') f.push(['disabled', '=', 0])
|
||||||
|
else if (filters.statut === 'disabled') f.push(['disabled', '=', 1])
|
||||||
|
if (filters.territory) f.push(['territory', '=', filters.territory])
|
||||||
|
if (filters.group) f.push(['customer_group', '=', filters.group])
|
||||||
|
const CFIELDS = ['name', 'customer_name', 'email_id', 'language', 'territory', 'customer_group']
|
||||||
|
let customers = []
|
||||||
|
if (filters.active_sub) {
|
||||||
|
// Clients ayant ≥1 Service Subscription « Actif ».
|
||||||
|
const subs = []; let s = 0
|
||||||
|
for (let p = 0; p < 30; p++) { const b = await erp.list('Service Subscription', { filters: [['status', '=', 'Actif']], fields: ['customer'], limit: 500, start: s }); subs.push(...b); if (b.length < 500) break; s += 500 }
|
||||||
|
const ids = [...new Set(subs.map(x => x.customer).filter(Boolean))]
|
||||||
|
for (let i = 0; i < ids.length; i += 100) {
|
||||||
|
const b = await erp.list('Customer', { filters: [...f, ['name', 'in', ids.slice(i, i + 100)]], fields: CFIELDS, limit: 500 })
|
||||||
|
customers.push(...b)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let s = 0
|
||||||
|
for (let p = 0; p < 60; p++) { const b = await erp.list('Customer', { filters: f, fields: CFIELDS, limit: 500, start: s }); customers.push(...b); if (b.length < 500) break; s += 500 }
|
||||||
|
}
|
||||||
|
rows = customers.map(c => { const n = splitName(c.customer_name); return { email: firstEmail(c.email_id), firstname: n.firstname, lastname: n.lastname, language: normLang(c.language), customer_id: c.name } })
|
||||||
|
}
|
||||||
|
const seen = new Set(); const recipients = []; let dropped = 0
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!EMAIL_RE.test(r.email || '')) { dropped++; continue }
|
||||||
|
const k = r.email.toLowerCase(); if (seen.has(k)) continue; seen.add(k)
|
||||||
|
recipients.push(r)
|
||||||
|
}
|
||||||
|
return { recipients, count: recipients.length, dropped_no_email: dropped }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Page publique (festive, mobile-first, bilingue) ────────────────────────
|
// ── Page publique (festive, mobile-first, bilingue) ────────────────────────
|
||||||
function field (id, nameAttr, labelHtml, inputHtml, errId, errMsg) {
|
function field (id, nameAttr, labelHtml, inputHtml, errId, errMsg) {
|
||||||
return `<label for="${id}">${labelHtml}</label>${inputHtml}${errId ? `<div class="err" id="${errId}">${esc(errMsg)}</div>` : ''}`
|
return `<label for="${id}">${labelHtml}</label>${inputHtml}${errId ? `<div class="err" id="${errId}">${esc(errMsg)}</div>` : ''}`
|
||||||
|
|
@ -486,9 +556,18 @@ async function handle (req, res, method, path, url) {
|
||||||
}
|
}
|
||||||
return json(res, 400, { error: 'mass_send_not_enabled', message: "Envoi de masse pas encore activé — choisir l'audience d'abord." })
|
return json(res, 400, { error: 'mass_send_not_enabled', message: "Envoi de masse pas encore activé — choisir l'audience d'abord." })
|
||||||
}
|
}
|
||||||
|
// Staff : POST /events/<id>/audience {mode:'filter'|'csv', filters, csv} → aperçu (décompte + échantillon)
|
||||||
|
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience$/)
|
||||||
|
if (mm && method === 'POST') {
|
||||||
|
if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
|
||||||
|
const b = await parseBody(req)
|
||||||
|
const r = await resolveAudience({ mode: b.mode, filters: b.filters || {}, csv: b.csv || '' })
|
||||||
|
if (r.error) return json(res, 400, { error: r.error })
|
||||||
|
return json(res, 200, { count: r.count, dropped_no_email: r.dropped_no_email, sample: r.recipients.slice(0, 50) })
|
||||||
|
}
|
||||||
|
|
||||||
return json(res, 404, { error: 'not found' })
|
return json(res, 404, { error: 'not found' })
|
||||||
} catch (e) { log('events handle: ' + e.message); return json(res, 500, { error: e.message }) }
|
} catch (e) { log('events handle: ' + e.message); return json(res, 500, { error: e.message }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, sendTest, matchAndValidate, page, normLang }
|
module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, sendTest, matchAndValidate, resolveAudience, parseAudienceCsv, page, normLang }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user