feat(ops/campaigns): explicit contact↔shortlink pairing review before approve

Step 2 of the new-campaign wizard previously dropped unpaired contacts
silently (Math.min(contacts, gifts) iteration) — if you uploaded 5
contacts and 3 gift links, you got 3 recipients in the table with no
visible signal that 2 contacts were left out. Step 1 only showed
"contacts skipped: N" in a small banner, easy to miss.

Surface the imbalance explicitly so the user can decide before sending:

Backend (POST /campaigns/parse):
- Return unpaired_contacts[] and unused_gifts[] arrays (with row_index
  for source-CSV cross-reference), in addition to the existing
  recipients[]. Old leftover_gifts / leftover_contacts counters kept
  for backward compat.

UI (CampaignNewPage Step 2):
- New columns in the recipients table:
  • # (row index from the source CSVs)
  • Lien-cadeau (truncated shortlink, clickable to verify)
  These let the user eyeball the contact↔link pairing line by line.
- New counter strip:
  Paires / À envoyer / Client lié / Sans client / Sans lien / Liens surplus
- "Sans lien" and "Liens surplus" counters appear only when relevant.
- Explicit warning banner explaining what unpaired/unused means
  (acquire more links and re-upload, or proceed knowing N won't get).
- Expansion panel listing each unpaired contact with their row_index +
  details, so the user can verify which specific contacts will be
  excluded before approving.
- Expansion panel listing each unused gift URL (extra capacity).
- "Approuver" button now shows the exact send count: "Approuver — N à
  envoyer". Disabled when 0. Step 3 recap also reflects sendableCount.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-05-21 20:31:44 -04:00
parent 0186a7318e
commit 8d9e190c21
2 changed files with 151 additions and 36 deletions

View File

@ -73,48 +73,86 @@
<!-- Step 2 Preview matched send list -->
<q-step :name="2" title="Aperçu et matching" icon="preview" :done="step > 2" :header-nav="step > 2">
<div class="row q-col-gutter-md q-mb-md">
<q-card flat bordered class="col-12 col-md-3">
<q-card-section class="text-center">
<div class="text-h4">{{ recipients.length }}</div>
<div class="text-caption text-grey-7">Total destinataires</div>
<!-- Counter strip at a glance: total / paired / sendable / unmatched -->
<div class="row q-col-gutter-sm q-mb-md">
<q-card flat bordered class="col-6 col-md">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5">{{ recipients.length }}</div>
<div class="text-caption text-grey-7">Paires contact lien</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-12 col-md-3">
<q-card-section class="text-center">
<div class="text-h4 text-positive">{{ matchedCount }}</div>
<div class="text-caption text-grey-7">Client ERPNext lié</div>
<q-card flat bordered class="col-6 col-md" :class="{ 'bg-positive-1': sendableCount > 0 }">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-positive">{{ sendableCount }}</div>
<div class="text-caption text-grey-7">À envoyer</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-12 col-md-3">
<q-card-section class="text-center">
<div class="text-h4 text-warning">{{ unmatchedCount }}</div>
<div class="text-caption text-grey-7">Non liés (review)</div>
<q-card flat bordered class="col-6 col-md">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-positive">{{ matchedCount }}</div>
<div class="text-caption text-grey-7">Client lié</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-12 col-md-3">
<q-card-section class="text-center">
<div class="text-h4 text-grey-7">{{ excludedCount }}</div>
<div class="text-caption text-grey-7">Exclus</div>
<q-card flat bordered class="col-6 col-md">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-warning">{{ unmatchedCount }}</div>
<div class="text-caption text-grey-7">Sans client</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-6 col-md" v-if="unpairedContacts.length">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-negative">{{ unpairedContacts.length }}</div>
<div class="text-caption text-grey-7">Sans lien</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-6 col-md" v-if="unusedGifts.length">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-orange">{{ unusedGifts.length }}</div>
<div class="text-caption text-grey-7">Liens en surplus</div>
</q-card-section>
</q-card>
</div>
<q-banner v-if="parseInfo.leftover_gifts || parseInfo.leftover_contacts" class="bg-orange-1 text-orange-9 q-mb-md" rounded>
<!-- Imbalance banner: explicit explanation of what the imbalance means -->
<q-banner v-if="unpairedContacts.length || unusedGifts.length" class="bg-orange-1 text-orange-9 q-mb-md" rounded>
<template v-slot:avatar><q-icon name="warning" /></template>
Désalignement détecté: {{ parseInfo.leftover_gifts }} carte(s) sans contact,
{{ parseInfo.leftover_contacts }} contact(s) sans carte. Vérifier l'ordre des CSV.
<div v-if="unpairedContacts.length">
<strong>{{ unpairedContacts.length }} contact(s) sans lien-cadeau</strong>
ils n'apparaissent PAS dans la liste d'envoi ci-dessous et ne recevront rien.
Pour les inclure, acquérir {{ unpairedContacts.length }} liens supplémentaires
chez Giftbit et re-uploader le fichier.
</div>
<div v-if="unusedGifts.length" :class="unpairedContacts.length ? 'q-mt-xs' : ''">
<strong>{{ unusedGifts.length }} lien(s) Giftbit non utilisés</strong>
il y a plus de cartes-cadeaux que de contacts. Le surplus sera perdu si
la campagne est envoyée tel quel.
</div>
</q-banner>
<!-- Paired recipients (will be sent) -->
<div class="text-subtitle1 q-mb-xs">
<q-icon name="link" /> Association contact lien-cadeau
<span class="text-caption text-grey-7"> vérifier avant d'approuver</span>
</div>
<q-table
:rows="recipients" :columns="recipientColumns" row-key="email"
:rows="recipients" :columns="recipientColumns" row-key="row_index"
flat bordered dense :pagination="{ rowsPerPage: 25 }"
:rows-per-page-options="[10, 25, 50, 100, 0]"
class="q-mb-md"
>
<template v-slot:body-cell-row_index="props">
<q-td :props="props" class="text-grey-6">#{{ props.row.row_index }}</q-td>
</template>
<template v-slot:body-cell-gift_url="props">
<q-td :props="props">
<a :href="props.row.gift_url" target="_blank" style="color:var(--q-primary); font-family:monospace; font-size:0.78rem">
{{ shortenUrl(props.row.gift_url) }}
</a>
</q-td>
</template>
<template v-slot:body-cell-match="props">
<q-td :props="props">
<q-chip v-if="props.row.customer_id" dense color="positive" text-color="white" size="sm"
:label="props.row.match_method" />
<q-chip v-if="props.row.customer_id" dense color="positive" text-color="white" size="sm" :label="props.row.match_method" />
<q-chip v-else dense color="warning" text-color="white" size="sm" label="non lié" />
</q-td>
</template>
@ -129,10 +167,40 @@
</template>
</q-table>
<!-- Contacts that have NO gift-url (won't be sent) -->
<q-expansion-item v-if="unpairedContacts.length" expand-separator
icon="person_off" :label="`${unpairedContacts.length} contact(s) sans lien-cadeau (ne recevront pas)`"
header-class="bg-red-1 text-red-9">
<q-table
:rows="unpairedContacts" :columns="unpairedColumns" row-key="row_index"
flat dense hide-bottom :pagination="{ rowsPerPage: 0 }"
>
<template v-slot:body-cell-row_index="props">
<q-td :props="props" class="text-grey-6">#{{ props.row.row_index }}</q-td>
</template>
</q-table>
</q-expansion-item>
<!-- Unused gift URLs (extra capacity) -->
<q-expansion-item v-if="unusedGifts.length" expand-separator
icon="card_giftcard"
:label="`${unusedGifts.length} lien(s) Giftbit non utilisés`"
header-class="bg-orange-1 text-orange-9"
class="q-mt-sm">
<q-list dense>
<q-item v-for="g in unusedGifts" :key="g.gift_url">
<q-item-section side class="text-grey-6">#{{ g.row_index }}</q-item-section>
<q-item-section>
<a :href="g.gift_url" target="_blank" style="color:var(--q-primary); font-family:monospace; font-size:0.85rem">{{ g.gift_url }}</a>
</q-item-section>
</q-item>
</q-list>
</q-expansion-item>
<q-stepper-navigation>
<q-btn flat label="Retour" @click="step = 1" />
<q-btn unelevated color="primary" label="Confirmer et envoyer" icon-right="send"
class="q-ml-sm" @click="step = 3" :disable="recipients.length === 0" />
<q-btn unelevated color="primary" :label="`Approuver — ${sendableCount} à envoyer`" icon-right="send"
class="q-ml-sm" @click="step = 3" :disable="sendableCount === 0" />
</q-stepper-navigation>
</q-step>
@ -143,7 +211,7 @@
<div class="text-subtitle1 q-mb-sm">Récapitulatif</div>
<q-list dense>
<q-item><q-item-section side>Nom</q-item-section><q-item-section>{{ params.name }}</q-item-section></q-item>
<q-item><q-item-section side>Destinataires actifs</q-item-section><q-item-section>{{ recipients.length - excludedCount }} (sur {{ recipients.length }} totaux)</q-item-section></q-item>
<q-item><q-item-section side>Destinataires actifs</q-item-section><q-item-section>{{ sendableCount }} (sur {{ recipients.length }} paires ; {{ excludedCount }} exclus, {{ unpairedContacts.length }} sans lien)</q-item-section></q-item>
<q-item><q-item-section side>Montant affiché</q-item-section><q-item-section>{{ params.amount }}</q-item-section></q-item>
<q-item><q-item-section side>Engagement</q-item-section><q-item-section>{{ params.commitment_months }} mois</q-item-section></q-item>
<q-item><q-item-section side>Sujet</q-item-section><q-item-section>{{ params.subject }}</q-item-section></q-item>
@ -202,28 +270,50 @@ const multiOptions = [
const parsing = ref(false)
const sending = ref(false)
const recipients = ref([])
const parseInfo = ref({ leftover_gifts: 0, leftover_contacts: 0 })
const unpairedContacts = ref([])
const unusedGifts = ref([])
// row_index (#1, #2, ...) is the source-CSV position invaluable for the
// user to cross-reference what they see here against the file they uploaded.
// gift_url is rendered as a clickable short label so contactlink pairing
// can be eyeballed at a glance and the user can click through to verify
// the shortlink works.
const recipientColumns = [
{ name: 'row_index', label: '#', field: 'row_index', align: 'left' },
{ name: 'firstname', label: 'Prénom', field: 'firstname', align: 'left' },
{ name: 'lastname', label: 'Nom', field: 'lastname', align: 'left' },
{ name: 'email', label: 'Email', field: 'email', align: 'left' },
{ name: 'civic_address', label: 'Adresse', field: 'civic_address', align: 'left' },
{ name: 'postal_code', label: 'Code postal', field: 'postal_code', align: 'left' },
{ name: 'gift_url', label: 'Lien-cadeau', field: 'gift_url', align: 'left' },
{ name: 'match', label: 'Match client', field: 'match_method', align: 'left' },
{ name: 'customer_name', label: 'Client ERPNext', field: 'customer_name', align: 'left' },
{ name: 'actions', label: '', field: '', align: 'right' },
]
const unpairedColumns = [
{ name: 'row_index', label: '#', field: 'row_index', align: 'left' },
{ name: 'firstname', label: 'Prénom', field: 'firstname', align: 'left' },
{ name: 'lastname', label: 'Nom', field: 'lastname', align: 'left' },
{ name: 'email', label: 'Email', field: 'email', align: 'left' },
{ name: 'civic_address', label: 'Adresse', field: 'civic_address', align: 'left' },
{ name: 'postal_code', label: 'Code postal', field: 'postal_code', align: 'left' },
]
const matchedCount = computed(() => recipients.value.filter(r => r.customer_id).length)
const unmatchedCount = computed(() => recipients.value.filter(r => !r.customer_id).length)
const excludedCount = computed(() => recipients.value.filter(r => r.excluded).length)
// Net number of emails that will actually be fired off (paired AND not excluded)
const sendableCount = computed(() => recipients.value.filter(r => !r.excluded && r.gift_url).length)
const estimatedMinutes = computed(() => {
const n = recipients.value.length - excludedCount.value
const per = (params.value.throttle_ms || 600) / 1000
return Math.max(1, Math.round((n * per) / 60))
return Math.max(1, Math.round((sendableCount.value * per) / 60))
})
function shortenUrl (u) {
if (!u) return ''
return u.replace(/^https?:\/\//, '').slice(0, 28) + (u.length > 35 ? '…' : '')
}
function readFile (file) {
return new Promise((resolve, reject) => {
const r = new FileReader()
@ -278,8 +368,9 @@ async function goPreview () {
parsing.value = true
try {
const r = await parseCsvs({ map_csv: mapPreview.value, giftbit_csv: giftPreview.value, multi: params.value.multi })
recipients.value = r.recipients || []
parseInfo.value = { leftover_gifts: r.leftover_gifts || 0, leftover_contacts: r.leftover_contacts || 0 }
recipients.value = r.recipients || []
unpairedContacts.value = r.unpaired_contacts || []
unusedGifts.value = r.unused_gifts || []
step.value = 2
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur de parsing: ' + e.message })

View File

@ -612,6 +612,13 @@ function applyWebhookEvent (ev) {
// ── HTTP routing ─────────────────────────────────────────────────────────────
async function handle (req, res, method, path) {
// POST /campaigns/parse — preview matched send list (no save)
// Returns:
// - recipients[]: contacts paired with a gift_url (ready to send)
// - unpaired_contacts[]: contacts with no matching gift (won't be sent —
// these are surfaced so the user can decide whether to acquire more
// gift links and re-upload, or proceed anyway)
// - unused_gifts[]: gift URLs with no matching contact (lost capacity —
// surfaced so the user knows the imbalance)
if (path === '/campaigns/parse' && method === 'POST') {
const body = await parseBody(req)
const { map_csv, giftbit_csv, multi } = body || {}
@ -621,16 +628,17 @@ async function handle (req, res, method, path) {
const { contacts, skipped } = parseMapCsv(map_csv, multi || 'first')
const gifts = parseGiftbitCsv(giftbit_csv)
// Match contacts ↔ gifts (by row order, fallback to email)
// Match contacts ↔ gifts (by row order, fallback to email when Giftbit
// echoed back our recipient email in their export)
const recipients = []
const n = Math.min(contacts.length, gifts.length)
for (let i = 0; i < n; i++) {
const c = contacts[i]; const g = gifts[i]
// If Giftbit echoed back our email, prefer that match for robustness
const giftByEmail = gifts.find(gg => normalizeEmail(gg.email) === c.email)
const gift = giftByEmail || g
const match = await matchCustomer(c)
recipients.push({
row_index: i + 1,
...c,
gift_url: gift.gift_url,
giftbit_uuid: gift.giftbit_uuid,
@ -640,10 +648,26 @@ async function handle (req, res, method, path) {
excluded: false,
})
}
// Capture the imbalance: leftover contacts have no gift, leftover gifts
// have no contact. Returning them as arrays (not just counts) lets the
// UI render them so the user makes an informed decision.
const unpaired_contacts = contacts.slice(n).map((c, idx) => ({
row_index: n + idx + 1,
...c,
}))
const unused_gifts = gifts.slice(n).map((g, idx) => ({
row_index: n + idx + 1,
...g,
}))
return json(res, 200, {
recipients,
leftover_gifts: gifts.length - n,
leftover_contacts: contacts.length - n,
unpaired_contacts,
unused_gifts,
// Kept for backward compat with any older callers
leftover_gifts: unused_gifts.length,
leftover_contacts: unpaired_contacts.length,
skipped,
})
}