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 ─────────────────────────── -->
|
<!-- Step 2 — Preview matched send list ─────────────────────────── -->
|
||||||
<q-step :name="2" title="Aperçu et matching" icon="preview" :done="step > 2" :header-nav="step > 2">
|
<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">
|
<!-- Counter strip — at a glance: total / paired / sendable / unmatched -->
|
||||||
<q-card flat bordered class="col-12 col-md-3">
|
<div class="row q-col-gutter-sm q-mb-md">
|
||||||
<q-card-section class="text-center">
|
<q-card flat bordered class="col-6 col-md">
|
||||||
<div class="text-h4">{{ recipients.length }}</div>
|
<q-card-section class="text-center q-pa-sm">
|
||||||
<div class="text-caption text-grey-7">Total destinataires</div>
|
<div class="text-h5">{{ recipients.length }}</div>
|
||||||
|
<div class="text-caption text-grey-7">Paires contact ↔ lien</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
<q-card flat bordered class="col-12 col-md-3">
|
<q-card flat bordered class="col-6 col-md" :class="{ 'bg-positive-1': sendableCount > 0 }">
|
||||||
<q-card-section class="text-center">
|
<q-card-section class="text-center q-pa-sm">
|
||||||
<div class="text-h4 text-positive">{{ matchedCount }}</div>
|
<div class="text-h5 text-positive">{{ sendableCount }}</div>
|
||||||
<div class="text-caption text-grey-7">Client ERPNext lié</div>
|
<div class="text-caption text-grey-7">À envoyer</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
<q-card flat bordered class="col-12 col-md-3">
|
<q-card flat bordered class="col-6 col-md">
|
||||||
<q-card-section class="text-center">
|
<q-card-section class="text-center q-pa-sm">
|
||||||
<div class="text-h4 text-warning">{{ unmatchedCount }}</div>
|
<div class="text-h5 text-positive">{{ matchedCount }}</div>
|
||||||
<div class="text-caption text-grey-7">Non liés (review)</div>
|
<div class="text-caption text-grey-7">Client lié</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
<q-card flat bordered class="col-12 col-md-3">
|
<q-card flat bordered class="col-6 col-md">
|
||||||
<q-card-section class="text-center">
|
<q-card-section class="text-center q-pa-sm">
|
||||||
<div class="text-h4 text-grey-7">{{ excludedCount }}</div>
|
<div class="text-h5 text-warning">{{ unmatchedCount }}</div>
|
||||||
<div class="text-caption text-grey-7">Exclus</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-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</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>
|
<template v-slot:avatar><q-icon name="warning" /></template>
|
||||||
Désalignement détecté: {{ parseInfo.leftover_gifts }} carte(s) sans contact,
|
<div v-if="unpairedContacts.length">
|
||||||
{{ parseInfo.leftover_contacts }} contact(s) sans carte. Vérifier l'ordre des CSV.
|
<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>
|
</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
|
<q-table
|
||||||
:rows="recipients" :columns="recipientColumns" row-key="email"
|
:rows="recipients" :columns="recipientColumns" row-key="row_index"
|
||||||
flat bordered dense :pagination="{ rowsPerPage: 25 }"
|
flat bordered dense :pagination="{ rowsPerPage: 25 }"
|
||||||
:rows-per-page-options="[10, 25, 50, 100, 0]"
|
: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">
|
<template v-slot:body-cell-match="props">
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
<q-chip v-if="props.row.customer_id" dense color="positive" text-color="white" size="sm"
|
<q-chip v-if="props.row.customer_id" dense color="positive" text-color="white" size="sm" :label="props.row.match_method" />
|
||||||
:label="props.row.match_method" />
|
|
||||||
<q-chip v-else dense color="warning" text-color="white" size="sm" label="non lié" />
|
<q-chip v-else dense color="warning" text-color="white" size="sm" label="non lié" />
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -129,10 +167,40 @@
|
||||||
</template>
|
</template>
|
||||||
</q-table>
|
</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-stepper-navigation>
|
||||||
<q-btn flat label="Retour" @click="step = 1" />
|
<q-btn flat label="Retour" @click="step = 1" />
|
||||||
<q-btn unelevated color="primary" label="Confirmer et envoyer" icon-right="send"
|
<q-btn unelevated color="primary" :label="`Approuver — ${sendableCount} à envoyer`" icon-right="send"
|
||||||
class="q-ml-sm" @click="step = 3" :disable="recipients.length === 0" />
|
class="q-ml-sm" @click="step = 3" :disable="sendableCount === 0" />
|
||||||
</q-stepper-navigation>
|
</q-stepper-navigation>
|
||||||
</q-step>
|
</q-step>
|
||||||
|
|
||||||
|
|
@ -143,7 +211,7 @@
|
||||||
<div class="text-subtitle1 q-mb-sm">Récapitulatif</div>
|
<div class="text-subtitle1 q-mb-sm">Récapitulatif</div>
|
||||||
<q-list dense>
|
<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>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>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>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>
|
<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 parsing = ref(false)
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
const recipients = ref([])
|
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 = [
|
const recipientColumns = [
|
||||||
|
{ name: 'row_index', label: '#', field: 'row_index', align: 'left' },
|
||||||
{ name: 'firstname', label: 'Prénom', field: 'firstname', align: 'left' },
|
{ name: 'firstname', label: 'Prénom', field: 'firstname', align: 'left' },
|
||||||
{ name: 'lastname', label: 'Nom', field: 'lastname', align: 'left' },
|
{ name: 'lastname', label: 'Nom', field: 'lastname', align: 'left' },
|
||||||
{ name: 'email', label: 'Email', field: 'email', align: 'left' },
|
{ name: 'email', label: 'Email', field: 'email', align: 'left' },
|
||||||
{ name: 'civic_address', label: 'Adresse', field: 'civic_address', 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: 'match', label: 'Match client', field: 'match_method', align: 'left' },
|
||||||
{ name: 'customer_name', label: 'Client ERPNext', field: 'customer_name', align: 'left' },
|
{ name: 'customer_name', label: 'Client ERPNext', field: 'customer_name', align: 'left' },
|
||||||
{ name: 'actions', label: '', field: '', align: 'right' },
|
{ 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 matchedCount = computed(() => recipients.value.filter(r => r.customer_id).length)
|
||||||
const unmatchedCount = 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)
|
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 estimatedMinutes = computed(() => {
|
||||||
const n = recipients.value.length - excludedCount.value
|
|
||||||
const per = (params.value.throttle_ms || 600) / 1000
|
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) {
|
function readFile (file) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const r = new FileReader()
|
const r = new FileReader()
|
||||||
|
|
@ -279,7 +369,8 @@ async function goPreview () {
|
||||||
try {
|
try {
|
||||||
const r = await parseCsvs({ map_csv: mapPreview.value, giftbit_csv: giftPreview.value, multi: params.value.multi })
|
const r = await parseCsvs({ map_csv: mapPreview.value, giftbit_csv: giftPreview.value, multi: params.value.multi })
|
||||||
recipients.value = r.recipients || []
|
recipients.value = r.recipients || []
|
||||||
parseInfo.value = { leftover_gifts: r.leftover_gifts || 0, leftover_contacts: r.leftover_contacts || 0 }
|
unpairedContacts.value = r.unpaired_contacts || []
|
||||||
|
unusedGifts.value = r.unused_gifts || []
|
||||||
step.value = 2
|
step.value = 2
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$q.notify({ type: 'negative', message: 'Erreur de parsing: ' + e.message })
|
$q.notify({ type: 'negative', message: 'Erreur de parsing: ' + e.message })
|
||||||
|
|
|
||||||
|
|
@ -612,6 +612,13 @@ function applyWebhookEvent (ev) {
|
||||||
// ── HTTP routing ─────────────────────────────────────────────────────────────
|
// ── HTTP routing ─────────────────────────────────────────────────────────────
|
||||||
async function handle (req, res, method, path) {
|
async function handle (req, res, method, path) {
|
||||||
// POST /campaigns/parse — preview matched send list (no save)
|
// 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') {
|
if (path === '/campaigns/parse' && method === 'POST') {
|
||||||
const body = await parseBody(req)
|
const body = await parseBody(req)
|
||||||
const { map_csv, giftbit_csv, multi } = body || {}
|
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 { contacts, skipped } = parseMapCsv(map_csv, multi || 'first')
|
||||||
const gifts = parseGiftbitCsv(giftbit_csv)
|
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 recipients = []
|
||||||
const n = Math.min(contacts.length, gifts.length)
|
const n = Math.min(contacts.length, gifts.length)
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const c = contacts[i]; const g = gifts[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 giftByEmail = gifts.find(gg => normalizeEmail(gg.email) === c.email)
|
||||||
const gift = giftByEmail || g
|
const gift = giftByEmail || g
|
||||||
const match = await matchCustomer(c)
|
const match = await matchCustomer(c)
|
||||||
recipients.push({
|
recipients.push({
|
||||||
|
row_index: i + 1,
|
||||||
...c,
|
...c,
|
||||||
gift_url: gift.gift_url,
|
gift_url: gift.gift_url,
|
||||||
giftbit_uuid: gift.giftbit_uuid,
|
giftbit_uuid: gift.giftbit_uuid,
|
||||||
|
|
@ -640,10 +648,26 @@ async function handle (req, res, method, path) {
|
||||||
excluded: false,
|
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, {
|
return json(res, 200, {
|
||||||
recipients,
|
recipients,
|
||||||
leftover_gifts: gifts.length - n,
|
unpaired_contacts,
|
||||||
leftover_contacts: contacts.length - n,
|
unused_gifts,
|
||||||
|
// Kept for backward compat with any older callers
|
||||||
|
leftover_gifts: unused_gifts.length,
|
||||||
|
leftover_contacts: unpaired_contacts.length,
|
||||||
skipped,
|
skipped,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user