gigafibre-fsm/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue
louispaulb 8d9e190c21 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>
2026-05-21 20:31:44 -04:00

399 lines
20 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<q-page padding>
<div class="row items-center q-mb-md">
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" />
<div class="text-h5">Nouvelle campagne</div>
</div>
<q-stepper v-model="step" header-nav ref="stepper" color="primary" animated flat bordered class="bg-white">
<!-- Step 1 — Upload + parameters ─────────────────────────────────── -->
<q-step :name="1" title="Fichiers + paramètres" icon="upload_file" :done="step > 1" :header-nav="step > 1">
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-card flat bordered>
<q-card-section>
<div class="text-subtitle1 q-mb-sm">1. Export Map CSV (brut)</div>
<div class="text-caption text-grey-7 q-mb-md">
Le fichier <code>selectionAdressesMap*.csv</code> tel qu'exporté de la sélection
d'adresses (pipe-delimited, préambule de 1 ligne accepté).
</div>
<q-file v-model="mapFile" label="Sélectionner le fichier" accept=".csv" outlined dense @update:model-value="readMapFile">
<template v-slot:prepend><q-icon name="attach_file" /></template>
</q-file>
<div v-if="mapPreview" class="text-caption q-mt-sm text-grey-7">
✓ {{ countMapRows(mapPreview) }} lignes de contacts (préambule + header retirés)
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-6">
<q-card flat bordered>
<q-card-section>
<div class="text-subtitle1 q-mb-sm">2. Shortlinks Giftbit CSV</div>
<div class="text-caption text-grey-7 q-mb-md">
Le fichier <code>giftbit-gifts-&lt;id&gt;.csv</code> retourné par
<code>create_giftbit_campaign.js</code> (colonnes: firstname, lastname, email,
gift_url, giftbit_uuid, gift_value_cents).
</div>
<q-file v-model="giftFile" label="Sélectionner le fichier" accept=".csv" outlined dense @update:model-value="readGiftFile">
<template v-slot:prepend><q-icon name="attach_file" /></template>
</q-file>
<div v-if="giftPreview" class="text-caption q-mt-sm text-grey-7">
✓ {{ countGiftRows(giftPreview) }} cartes-cadeaux
<span v-if="giftFormatHint" class="text-grey-6">(format: {{ giftFormatHint }})</span>
</div>
</q-card-section>
</q-card>
</div>
</div>
<q-card flat bordered class="q-mt-md">
<q-card-section>
<div class="text-subtitle1 q-mb-sm">Paramètres de la campagne</div>
<div class="row q-col-gutter-md">
<q-input v-model="params.name" label="Nom interne" outlined dense class="col-12 col-md-6" />
<q-input v-model="params.amount" label="Montant (affiché)" outlined dense class="col-6 col-md-3" placeholder="60 $" />
<q-input v-model.number="params.commitment_months" type="number" label="Engagement (mois)" outlined dense class="col-6 col-md-3" />
<q-input v-model="params.subject" label="Sujet du courriel" outlined dense class="col-12 col-md-6" />
<q-input v-model="params.from" label="Expéditeur (From)" outlined dense class="col-12 col-md-6" placeholder="TARGO <support@targointernet.com>" />
<q-input v-model="params.expiry" label="Expiration (texte affiché)" outlined dense class="col-12 col-md-6" placeholder="31 décembre 2026" />
<q-input v-model.number="params.throttle_ms" type="number" label="Throttle (ms entre envois)" outlined dense class="col-12 col-md-6" />
<q-select v-model="params.multi" :options="multiOptions" emit-value map-options label="Emails multiples (couples)" outlined dense class="col-12 col-md-6" />
</div>
</q-card-section>
</q-card>
<q-stepper-navigation>
<q-btn unelevated color="primary" label="Suivant — Aperçu" icon-right="arrow_forward"
:disable="!mapPreview || !giftPreview || parsing"
:loading="parsing" @click="goPreview" />
</q-stepper-navigation>
</q-step>
<!-- Step 2 — Preview matched send list ─────────────────────────── -->
<q-step :name="2" title="Aperçu et matching" icon="preview" :done="step > 2" :header-nav="step > 2">
<!-- 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-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-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-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>
<!-- 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>
<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="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-else dense color="warning" text-color="white" size="sm" label="non lié" />
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props" class="text-right">
<q-btn flat dense size="sm" :icon="props.row.excluded ? 'add_circle' : 'block'"
:color="props.row.excluded ? 'positive' : 'negative'"
@click="props.row.excluded = !props.row.excluded">
<q-tooltip>{{ props.row.excluded ? 'Inclure' : 'Exclure' }}</q-tooltip>
</q-btn>
</q-td>
</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="`Approuver — ${sendableCount} à envoyer`" icon-right="send"
class="q-ml-sm" @click="step = 3" :disable="sendableCount === 0" />
</q-stepper-navigation>
</q-step>
<!-- Step 3 — Confirm + send ──────────────────────────────────────── -->
<q-step :name="3" title="Confirmation" icon="send">
<q-card flat bordered>
<q-card-section>
<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>{{ 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>
<q-item><q-item-section side>Expéditeur</q-item-section><q-item-section>{{ params.from }}</q-item-section></q-item>
<q-item><q-item-section side>Throttle</q-item-section><q-item-section>{{ params.throttle_ms }} ms entre envois (≈ {{ Math.round((60 / (params.throttle_ms / 1000)) || 0) }} emails/min)</q-item-section></q-item>
<q-item><q-item-section side>Durée estimée</q-item-section><q-item-section>≈ {{ estimatedMinutes }} min</q-item-section></q-item>
</q-list>
</q-card-section>
<q-card-section class="bg-orange-1 text-orange-9">
<q-icon name="info" /> L'envoi démarre dès que vous cliquez ci-dessous.
Vous serez redirigé vers la page de progression en temps réel.
</q-card-section>
</q-card>
<q-stepper-navigation>
<q-btn flat label="Retour" @click="step = 2" />
<q-btn unelevated color="primary" label="Lancer l'envoi" icon-right="send"
class="q-ml-sm" :loading="sending" @click="launchSend" />
</q-stepper-navigation>
</q-step>
</q-stepper>
</q-page>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { parseCsvs, createCampaign, sendCampaign } from 'src/api/campaigns'
const $q = useQuasar()
const router = useRouter()
const step = ref(1)
const mapFile = ref(null)
const giftFile = ref(null)
const mapPreview = ref('')
const giftPreview = ref('')
const params = ref({
name: `Campagne ${new Date().toISOString().slice(0,10)}`,
amount: '60 $',
commitment_months: 3,
subject: '🎁 Un cadeau pour vous, de la part de TARGO',
from: 'TARGO <support@targointernet.com>',
expiry: '',
throttle_ms: 600,
multi: 'first',
})
const multiOptions = [
{ label: '1er email seulement (1 cadeau/foyer)', value: 'first' },
{ label: 'Séparer en 2 rangées (1 cadeau/personne)', value: 'split' },
{ label: 'Ignorer les couples', value: 'skip' },
]
const parsing = ref(false)
const sending = ref(false)
const recipients = ref([])
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: '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 per = (params.value.throttle_ms || 600) / 1000
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()
r.onload = () => resolve(r.result)
r.onerror = reject
r.readAsText(file, 'utf-8')
})
}
async function readMapFile (file) { if (file) mapPreview.value = await readFile(file) }
async function readGiftFile (file) { if (file) giftPreview.value = await readFile(file) }
// Counts must mirror the backend parser exactly so the user sees the same
// numbers in the preview as what Step 2 will receive.
// Map CSV format: 1-line title preamble + header row + N data rows.
// Returns N (the # of contact lines, excluding the preamble and header).
function countMapRows (text) {
if (!text) return 0
const lines = text.split(/\r?\n/).filter(l => l.trim())
// -1 for preamble, -1 for header
return Math.max(0, lines.length - 2)
}
// Giftbit CSV: TWO formats
// 1. "Link Order" — headerless, one URL per line (each URL = 1 gift)
// 2. "Campaign export" — header row + N data rows (-1 for header)
// Detect like the backend: first non-empty line is a bare URL with no
// separator → no header.
function countGiftRows (text) {
if (!text) return 0
const cleaned = text.replace(/^/, '').trim()
if (!cleaned) return 0
const firstLine = cleaned.split(/\r?\n/, 1)[0].trim()
const isLinkOrder = /^https?:\/\/\S+$/.test(firstLine) &&
!firstLine.includes(',') && !firstLine.includes('\t') && !firstLine.includes('|')
const lines = cleaned.split(/\r?\n/).filter(l => l.trim())
return isLinkOrder ? lines.length : Math.max(0, lines.length - 1)
}
// Show "Link Order" or "Campaign export" hint next to the gift count
const giftFormatHint = computed(() => {
if (!giftPreview.value) return ''
const firstLine = giftPreview.value.replace(/^/, '').trim().split(/\r?\n/, 1)[0].trim()
if (/^https?:\/\/\S+$/.test(firstLine) && !firstLine.includes(',') && !firstLine.includes('\t') && !firstLine.includes('|')) {
return 'Link Order'
}
return 'Campaign export'
})
async function goPreview () {
if (!mapPreview.value || !giftPreview.value) return
parsing.value = true
try {
const r = await parseCsvs({ map_csv: mapPreview.value, giftbit_csv: giftPreview.value, multi: params.value.multi })
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 })
} finally {
parsing.value = false
}
}
async function launchSend () {
sending.value = true
try {
const saved = await createCampaign({
name: params.value.name,
params: { ...params.value },
recipients: recipients.value,
})
await sendCampaign(saved.id)
router.push(`/campaigns/${saved.id}`)
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
sending.value = false
}
}
</script>