diff --git a/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue index 22e241d..df61092 100644 --- a/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue +++ b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue @@ -73,48 +73,86 @@ -
- - -
{{ recipients.length }}
-
Total destinataires
+ +
+ + +
{{ recipients.length }}
+
Paires contact ↔ lien
- - -
{{ matchedCount }}
-
Client ERPNext lié
+ + +
{{ sendableCount }}
+
À envoyer
- - -
{{ unmatchedCount }}
-
Non liés (review)
+ + +
{{ matchedCount }}
+
Client lié
- - -
{{ excludedCount }}
-
Exclus
+ + +
{{ unmatchedCount }}
+
Sans client
+
+
+ + +
{{ unpairedContacts.length }}
+
Sans lien
+
+
+ + +
{{ unusedGifts.length }}
+
Liens en surplus
- + + - Désalignement détecté: {{ parseInfo.leftover_gifts }} carte(s) sans contact, - {{ parseInfo.leftover_contacts }} contact(s) sans carte. Vérifier l'ordre des CSV. +
+ {{ unpairedContacts.length }} contact(s) sans lien-cadeau — + 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. +
+
+ {{ unusedGifts.length }} lien(s) Giftbit non utilisés — + il y a plus de cartes-cadeaux que de contacts. Le surplus sera perdu si + la campagne est envoyée tel quel. +
+ +
+ Association contact ↔ lien-cadeau + — vérifier avant d'approuver +
+ + @@ -129,10 +167,40 @@ + + + + + + + + + + + + #{{ g.row_index }} + + {{ g.gift_url }} + + + + + - + @@ -143,7 +211,7 @@
Récapitulatif
Nom{{ params.name }} - Destinataires actifs{{ recipients.length - excludedCount }} (sur {{ recipients.length }} totaux) + Destinataires actifs{{ sendableCount }} (sur {{ recipients.length }} paires ; {{ excludedCount }} exclus, {{ unpairedContacts.length }} sans lien) Montant affiché{{ params.amount }} Engagement{{ params.commitment_months }} mois Sujet{{ params.subject }} @@ -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 }) diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js index 3c64640..473e0f9 100644 --- a/services/targo-hub/lib/campaigns.js +++ b/services/targo-hub/lib/campaigns.js @@ -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, }) }