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>
399 lines
20 KiB
Vue
399 lines
20 KiB
Vue
<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-<id>.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>
|