feat(events): total mensuel activé seulement si « abonnement actif » + liste des comptes sans courriel

- Le champ « Total mensuel minimum » est désactivé tant que « Abonnement actif » n'est pas
  coché (le montant vient des abonnements actifs) ; il se remet à 0 si on décoche.
- L'aperçu d'audience liste maintenant les comptes SANS courriel (rattachables) avec un lien
  vers chaque fiche client (/clients/:id) → permet de valider/ajouter le courriel manquant.
  resolveAudience renvoie `no_email:[{customer_id,name}]` (plafonné 300) ; l'endpoint le transmet.

Vérifié prod : filtre abonnement actif + ≥40$/mois → 3102 destinataires · 284 sans courriel
(liste des 284 renvoyée avec noms + fiches). Champ montant désactivé par défaut, activé au toggle.
33/33 tests unitaires. Déployé hub + SPA.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-06 14:38:19 -04:00
parent 5d780187b0
commit 1ab899388c
2 changed files with 26 additions and 8 deletions

View File

@ -142,11 +142,11 @@
<q-select class="col-12 col-sm-6" 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-select class="col-12 col-sm-6" v-model="audFilters.customer_type" :options="[{ label: 'Tous types', value: '' }, { label: 'Résidentiel', value: 'Individual' }, { label: 'Commercial', value: 'Company' }]" dense outlined emit-value map-options label="Type de client" @update:model-value="audPreview = null" />
</div>
<q-toggle v-model="audFilters.active_sub" label="Abonnement actif seulement" @update:model-value="audPreview = null" />
<q-input v-model.number="audFilters.min_monthly" type="number" min="0" dense outlined label="Total mensuel minimum ($/mois)" @update:model-value="audPreview = null">
<q-toggle v-model="audFilters.active_sub" label="Abonnement actif seulement" @update:model-value="onActiveSubToggle" />
<q-input v-model.number="audFilters.min_monthly" type="number" min="0" dense outlined :disable="!audFilters.active_sub" label="Total mensuel minimum ($/mois)" @update:model-value="audPreview = null">
<template #prepend><q-icon name="attach_money" /></template>
</q-input>
<div class="text-caption text-grey-6">« Total mensuel » = somme des abonnements actifs du client (0 = aucun filtre). « Résidentiel/Commercial » = type de compte.</div>
<div class="text-caption text-grey-6">Le « total mensuel » (somme des abonnements actifs) requiert d'activer « Abonnement actif ». « Résidentiel/Commercial » = type de compte.</div>
</div>
<div v-else class="q-gutter-sm">
@ -163,6 +163,18 @@
<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>
<div v-if="audPreview.no_email && audPreview.no_email.length" class="q-mt-xs">
<a class="cursor-pointer text-weight-medium text-blue-10" @click="showNoEmail = !showNoEmail">
{{ showNoEmail ? '▾ Masquer' : '▸ Voir' }} les {{ audPreview.dropped_no_email }} comptes sans courriel (à valider)
</a>
<div v-if="showNoEmail" class="q-mt-xs q-pa-xs bg-white rounded-borders" style="max-height:170px;overflow:auto">
<div v-for="ne in audPreview.no_email" :key="ne.customer_id" class="text-caption">
<a :href="'#/clients/' + ne.customer_id" target="_blank" class="text-primary">{{ ne.name }}</a>
<span class="text-grey-6"> · {{ ne.customer_id }}</span>
</div>
<div v-if="audPreview.dropped_no_email > audPreview.no_email.length" class="text-caption text-grey-6 q-mt-xs">et {{ audPreview.dropped_no_email - audPreview.no_email.length }} autres (liste plafonnée à 300)</div>
</div>
</div>
</q-banner>
<q-banner dense rounded class="bg-grey-2 text-grey-8">
@ -220,6 +232,7 @@ const csvFile = ref(null)
const audCsvText = ref('')
const audPreview = ref(null)
const previewing = ref(false)
const showNoEmail = ref(false)
const publicUrl = computed(() => ev.value.public_url || '')
@ -301,8 +314,9 @@ function onCsvFile (file) {
rd.onload = () => { audCsvText.value = String(rd.result || '') }
rd.readAsText(file)
}
function onActiveSubToggle (v) { audPreview.value = null; if (!v) audFilters.value.min_monthly = 0 } // le montant n'a de sens qu'avec les abonnements actifs
async function doPreview () {
previewing.value = true; audPreview.value = null
previewing.value = true; audPreview.value = null; showNoEmail.value = false
try {
const body = audienceSource.value === 'csv' ? { mode: 'csv', csv: audCsvText.value } : { mode: 'filter', filters: audFilters.value }
audPreview.value = await previewAudience(EVENT_ID, body)

View File

@ -294,13 +294,17 @@ async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}
}
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, customer_type: c.customer_type || '', monthly: Math.round(c._monthly || 0) } })
}
const seen = new Set(); const recipients = []; let dropped = 0
const seen = new Set(); const recipients = []; const noEmail = []; let dropped = 0
for (const r of rows) {
if (!EMAIL_RE.test(r.email || '')) { dropped++; continue }
if (!EMAIL_RE.test(r.email || '')) {
dropped++
if (r.customer_id) noEmail.push({ customer_id: r.customer_id, name: ((r.firstname || '') + ' ' + (r.lastname || '')).trim() || r.customer_id }) // rattachable → lien fiche pour valider
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 }
return { recipients, count: recipients.length, dropped_no_email: dropped, no_email: noEmail }
}
// ── Page publique (festive, mobile-first, bilingue) ────────────────────────
@ -565,7 +569,7 @@ async function handle (req, res, method, path, url) {
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, 200, { count: r.count, dropped_no_email: r.dropped_no_email, sample: r.recipients.slice(0, 50), no_email: (r.no_email || []).slice(0, 300) })
}
return json(res, 404, { error: 'not found' })