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:
parent
0186a7318e
commit
8d9e190c21
|
|
@ -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 contact↔link 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 })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user