gigafibre-fsm/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue
louispaulb d5ee57acf2 feat(campaigns/wizard): inspectable dropped-row list with one-click recovery
parseMapCsv now collects the actual rows it drops (capped at 200),
each with its skip reason and the raw source-CSV columns (full_name,
email, phone, address, postal). Returned alongside the existing
counters as skipped_rows on the parse response.

Wizard Step 2 adds an "N ligne(s) du Map CSV non importée(s)"
expansion below the imbalance banner, showing:
  Ligne # | Raison | Nom au CSV | Email au CSV | Adresse | CP | →

The action column has a "Ajouter manuellement" button on rows that
have an email (duplicate, multi_skip) — clicking opens the manual-
add dialog pre-filled from the dropped row, so the operator can
recover the contact in two clicks. no_email rows can't be recovered
that way and don't get the button.

The source_row index is the Excel-relative line number (counting the
header) so the operator can cross-reference the actual file.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 11:55:48 -04:00

1029 lines
54 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.

This file contains Unicode characters that might be confused with other characters. 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>
<q-space />
<!-- Always-accessible jump-to-editor link. Opens in new tab so the
wizard's uploaded files + parsed recipients stay intact. Useful
when the user wants to tweak the template mid-import. -->
<q-btn flat color="primary" icon="palette" label="Éditer le template (nouvel onglet)"
type="a" href="/ops/#/campaigns/templates/gift-email-fr" target="_blank">
<q-tooltip>S'ouvre dans un nouvel onglet tes fichiers uploadés restent intacts ici</q-tooltip>
</q-btn>
</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" />
<!-- Internal-only expiry on our /g/<token> wrapper. Preset
buttons cover the typical cases; "Custom" reveals a
number input for any value between 1-365 days. -->
<div class="col-12 col-md-6">
<div class="text-caption text-grey-7 q-mb-xs">
Expiration interne du lien (avant réassignation possible)
<q-icon name="info" size="14px" class="q-ml-xs">
<q-tooltip max-width="320px">Délai après lequel notre lien intermédiaire <code>/g/&lt;token&gt;</code> cesse de rediriger. Le lien Giftbit sous-jacent reste valide chez Giftbit — utile pour réassigner un cadeau non utilisé à un autre client.</q-tooltip>
</q-icon>
</div>
<div class="row items-center q-gutter-xs">
<q-btn-toggle
:model-value="expiryPreset"
@update:model-value="setExpiryPreset"
:options="[
{ label: '15j', value: 15 },
{ label: '30j', value: 30 },
{ label: '60j', value: 60 },
{ label: '90j', value: 90 },
{ label: '180j', value: 180 },
{ label: 'Custom', value: 'custom' },
]"
unelevated dense color="grey-3" text-color="grey-9"
toggle-color="primary" toggle-text-color="white"
no-caps
/>
<q-input v-if="expiryPreset === 'custom'"
v-model.number="params.gift_expiry_days"
type="number" min="1" max="365"
dense outlined style="width: 120px"
suffix="jours" />
<span v-else class="text-caption text-grey-6 q-ml-sm">
= {{ params.gift_expiry_days }} jour{{ params.gift_expiry_days > 1 ? 's' : '' }}
</span>
</div>
</div>
<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" />
<!-- Per-language template override. Defaults to gift-email-fr /
gift-email-en. Lets the operator pick seasonal or A/B
variants without touching the code. -->
<q-select v-model="params.template_fr" :options="frTemplateOptions"
label="🇫🇷 Template français" outlined dense
class="col-12 col-md-6" emit-value map-options
:loading="loadingTemplates"
@popup-show="loadTemplateLists">
<template v-slot:append>
<q-btn flat dense round icon="refresh" size="sm" @click.stop="loadTemplateLists">
<q-tooltip>Rafraîchir la liste des templates</q-tooltip>
</q-btn>
</template>
<q-tooltip>Template envoyé aux destinataires marqués FR. Les templates -fr apparaissent en premier ; tu peux quand même choisir n'importe quel autre template.</q-tooltip>
</q-select>
<q-select v-model="params.template_en" :options="enTemplateOptions"
label="🇺🇸 Template anglais" outlined dense
class="col-12 col-md-6" emit-value map-options
:loading="loadingTemplates"
@popup-show="loadTemplateLists">
<template v-slot:append>
<q-btn flat dense round icon="refresh" size="sm" @click.stop="loadTemplateLists">
<q-tooltip>Rafraîchir la liste des templates</q-tooltip>
</q-btn>
</template>
<q-tooltip>Template envoyé aux destinataires marqués EN.</q-tooltip>
</q-select>
</div>
</q-card-section>
</q-card>
<q-stepper-navigation>
<q-btn flat color="primary" icon="person_add" label="Saisie manuelle (sans CSV)" @click="goManual" class="q-mr-sm">
<q-tooltip>Sauter l'import CSV et ajouter les destinataires un par un à l'étape suivante</q-tooltip>
</q-btn>
<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>
<q-card flat bordered class="col-6 col-md" v-if="namesNeedingReview">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-amber-9">{{ namesNeedingReview }}</div>
<div class="text-caption text-grey-7">Noms à vérifier</div>
</q-card-section>
</q-card>
</div>
<!-- Auto-clean summary — informational, not blocking -->
<q-banner v-if="namesAutoCorrected || namesNeedingReview" class="bg-blue-1 text-blue-9 q-mb-sm" rounded dense>
<template v-slot:avatar><q-icon name="auto_fix_high" /></template>
<span v-if="namesAutoCorrected">
<strong>{{ namesAutoCorrected }} nom(s) auto-corrigés</strong> (Title Case, accents québécois,
prénoms composés séparés). L'icône ✨ verte dans le tableau indique les changements.
</span>
<span v-if="namesNeedingReview" :class="namesAutoCorrected ? 'q-ml-md' : ''">
<strong>{{ namesNeedingReview }} nom(s) suspects</strong> — icône ⚠ amber : cliquer la cellule
pour éditer en place.
</span>
</q-banner>
<!-- 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>
<!-- Always-on parse summary: explains the imbalance precisely even
when no Map rows were dropped (= the Giftbit CSV simply had
more entries than the Map CSV upstream). -->
<div v-if="parseSkipped" class="q-mt-sm text-caption" style="color:#7C3F00">
<q-icon name="info" size="14px" />
<strong>Map CSV :</strong> {{ parseSkipped.total_rows }} ligne(s) brute(s) →
<strong>{{ recipients.length }} contact(s)</strong> pairé(s) avec un cadeau.
<span v-if="(parseSkipped.no_email + parseSkipped.duplicate + parseSkipped.multi_skip) === 0">
Aucun contact n'a été droppé au parsing — l'imbalance vient probablement du fait
que le CSV Giftbit a {{ unusedGifts.length }} ligne(s) de plus que le CSV Map.
Vérifie que le nombre de cartes-cadeaux générées chez Giftbit correspond bien au
fichier Map utilisé.
</span>
<span v-else>
Ventilation des droppés :
<span v-if="parseSkipped.no_email"> <strong>{{ parseSkipped.no_email }}</strong> sans email valide</span><span v-if="parseSkipped.no_email && (parseSkipped.duplicate || parseSkipped.multi_skip)"> ·</span>
<span v-if="parseSkipped.duplicate"> <strong>{{ parseSkipped.duplicate }}</strong> emails en double</span><span v-if="parseSkipped.duplicate && parseSkipped.multi_skip"> ·</span>
<span v-if="parseSkipped.multi_skip"> <strong>{{ parseSkipped.multi_skip }}</strong> couples ignorés (réglage "Emails multiples")</span>.
</span>
<span v-if="parseSkipped.no_name">
({{ parseSkipped.no_name }} ligne(s) sans nom ont été <em>gardées</em> et utiliseront "cher client" à l'envoi.)
</span>
</div>
</q-banner>
<!-- ✓ Paired recipients (will be sent) ────────────────────────── -->
<div class="row items-center q-mb-xs">
<div class="text-subtitle1">
<q-icon name="link" /> Association contact ↔ lien-cadeau
<span class="text-caption text-grey-7">— vérifier avant d'approuver</span>
</div>
<q-space />
<q-btn unelevated dense color="primary" icon="person_add" label="Ajouter manuellement" @click="openManualDialog">
<q-tooltip>Ajouter un destinataire en saisissant les champs (sans passer par CSV)</q-tooltip>
</q-btn>
</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>
<!-- Editable firstname cell with auto-clean indicators.
Click the cell → q-popup-edit opens, type new value, Enter saves.
Icons (left of name):
⚠ amber = nameWarning() heuristic flagged this (e.g. "deux prénoms collés")
✨ green = auto-cleaner changed something at parse-time
(Title Case, accent restoration, compound split) -->
<template v-slot:body-cell-firstname="props">
<q-td :props="props" style="cursor:pointer">
<q-icon v-if="props.row.name_warnings?.firstname" name="warning" color="amber-9" size="14px" class="q-mr-xs">
<q-tooltip>{{ props.row.name_warnings.firstname }}</q-tooltip>
</q-icon>
<q-icon v-else-if="props.row.cleaned_changed && props.row.firstname !== props.row.firstname_raw"
name="auto_fix_high" color="positive" size="14px" class="q-mr-xs">
<q-tooltip>Auto-corrigé depuis "<strong>{{ props.row.firstname_raw }}</strong>"</q-tooltip>
</q-icon>
{{ props.row.firstname }}
<q-popup-edit v-model="props.row.firstname" auto-save v-slot="scope" buttons label-set="OK" label-cancel="Annuler">
<q-input v-model="scope.value" dense autofocus :model-value="scope.value"
@keyup.enter="scope.set" @keyup.esc="scope.cancel" />
</q-popup-edit>
</q-td>
</template>
<template v-slot:body-cell-lastname="props">
<q-td :props="props" style="cursor:pointer">
<q-icon v-if="props.row.name_warnings?.lastname" name="warning" color="amber-9" size="14px" class="q-mr-xs">
<q-tooltip>{{ props.row.name_warnings.lastname }}</q-tooltip>
</q-icon>
<q-icon v-else-if="props.row.cleaned_changed && props.row.lastname !== props.row.lastname_raw"
name="auto_fix_high" color="positive" size="14px" class="q-mr-xs">
<q-tooltip>Auto-corrigé depuis "<strong>{{ props.row.lastname_raw }}</strong>"</q-tooltip>
</q-icon>
{{ props.row.lastname }}
<q-popup-edit v-model="props.row.lastname" auto-save v-slot="scope" buttons label-set="OK" label-cancel="Annuler">
<q-input v-model="scope.value" dense autofocus
@keyup.enter="scope.set" @keyup.esc="scope.cancel" />
</q-popup-edit>
</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-language="props">
<q-td :props="props">
<!-- Clickable chip — toggles FR ↔ EN inline. Default value comes
from the matched Customer.language (or 'fr' for unmatched).
The send-worker picks the template by this value at send
time, so flipping here changes which template gets used. -->
<q-chip dense clickable size="sm"
:color="props.row.language === 'en' ? 'blue-grey-6' : 'primary'"
text-color="white"
:label="(props.row.language || 'fr').toUpperCase()"
@click="props.row.language = props.row.language === 'en' ? 'fr' : 'en'">
<q-tooltip>Cliquer pour basculer FR ↔ EN</q-tooltip>
</q-chip>
</q-td>
</template>
<template v-slot:body-cell-match="props">
<q-td :props="props">
<q-chip v-if="props.row.manual" dense color="indigo-5" text-color="white" size="sm" icon="edit" label="manuel" />
<q-chip v-else-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>
<!-- ⚠ Lignes du Map CSV qui n'ont pas passé le parsing ─────────── -->
<q-expansion-item v-if="parseSkippedRows.length" expand-separator
icon="filter_alt_off"
:label="`${parseSkippedRows.length} ligne(s) du Map CSV non importée(s)`"
header-class="bg-red-1 text-red-9"
class="q-mt-sm">
<div class="q-pa-sm text-caption text-grey-7">
Chaque ligne ci-dessous a été <strong>droppée au parsing</strong> et n'apparaît pas
dans la liste d'envoi. Le numéro de ligne (#) correspond à la position dans le fichier
Excel original (header inclus).
</div>
<q-table
:rows="parseSkippedRows" :columns="skippedRowsColumns" row-key="source_row"
flat dense hide-bottom :pagination="{ rowsPerPage: 0 }"
>
<template v-slot:body-cell-source_row="props">
<q-td :props="props" class="text-grey-6">#{{ props.row.source_row }}</q-td>
</template>
<template v-slot:body-cell-reason="props">
<q-td :props="props">
<q-chip dense size="sm" :color="reasonColor(props.row.reason)" text-color="white"
:label="reasonLabel(props.row.reason)" />
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props" class="text-right">
<q-btn v-if="props.row.reason !== 'no_email'" flat dense size="sm" color="primary"
icon="person_add" @click="prefillManualFromSkipped(props.row)">
<q-tooltip>Ouvrir la dialog "Ajouter manuellement" avec ces champs pré-remplis</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
</q-expansion-item>
<!-- Action row: preview + edit template are quick-access utilities,
both non-destructive. The primary action is "Continuer" which
moves to Step 3 (still NOT the send — Step 3 has its own
explicit launch button). Icon changed from 'send' (confusing,
looked like it fired) to 'arrow_forward'. -->
<q-stepper-navigation>
<q-btn flat label="Retour" @click="step = 1" />
<q-space />
<q-btn flat color="primary" icon="visibility" label="Aperçu du courriel"
:disable="!firstPreviewable" @click="openPreview" class="q-mr-sm">
<q-tooltip>Voir le rendu du courriel avec les vraies données du destinataire #1 (n'envoie rien)</q-tooltip>
</q-btn>
<q-btn flat color="primary" icon="palette" label="Éditer le template"
type="a" :href="editorHref" target="_blank" class="q-mr-sm">
<q-tooltip>Ouvre l'éditeur dans un nouvel onglet — ton import reste ici intact</q-tooltip>
</q-btn>
<q-btn unelevated color="primary" :label="`Continuer — ${sendableCount} prêts`"
icon-right="arrow_forward"
@click="step = 3" :disable="sendableCount === 0">
<q-tooltip>Va à l'étape de confirmation finale. L'envoi ne démarre qu'au clic sur "Lancer l'envoi" de l'étape 3.</q-tooltip>
</q-btn>
</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>Répartition par langue</q-item-section><q-item-section>{{ langBreakdown }}</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>Template FR</q-item-section><q-item-section><code>{{ params.template_fr }}</code></q-item-section></q-item>
<q-item><q-item-section side>Template EN</q-item-section><q-item-section><code>{{ params.template_en }}</code></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-red-1 text-red-9">
<q-icon name="warning" /> <strong>Confirmation finale.</strong>
L'envoi démarre dès le clic sur <em>"Lancer l'envoi maintenant"</em>.
Tu seras redirigé vers la page de progression temps réel.
</q-card-section>
</q-card>
<q-stepper-navigation>
<q-btn flat label="Retour modifier" icon="arrow_back" @click="step = 2" />
<q-space />
<q-btn flat color="primary" icon="visibility" label="Aperçu courriel" :disable="!firstPreviewable" @click="openPreview" class="q-mr-sm" />
<q-btn unelevated color="negative" label="Lancer l'envoi maintenant" icon-right="send"
:loading="sending" @click="confirmAndLaunch" />
</q-stepper-navigation>
</q-step>
</q-stepper>
<!-- Manual-add dialog — push a recipient into recipients[] without going
through CSV parsing. Useful for ad-hoc gifts, retroactive top-ups,
or test sends to internal stakeholders. The matchCustomer() lookup
that the CSV path does is skipped here — customer_id stays empty
unless the user manually pastes it. -->
<q-dialog v-model="manualOpen" persistent>
<q-card style="min-width: 480px; max-width: 640px;">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6"><q-icon name="person_add" class="q-mr-sm" />Ajouter un destinataire</div>
<q-space />
<q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<q-form @submit="submitManualRow" class="q-gutter-sm">
<div class="row q-col-gutter-sm">
<q-input v-model="manualRow.firstname" label="Prénom *" outlined dense
class="col-12 col-sm-6" :rules="[v => !!v || 'requis']" autofocus />
<q-input v-model="manualRow.lastname" label="Nom" outlined dense
class="col-12 col-sm-6" />
</div>
<q-input v-model="manualRow.email" label="Email *" outlined dense type="email"
:rules="[v => !!v || 'requis', v => /.+@.+\..+/.test(v) || 'email invalide']" />
<q-input v-model="manualRow.gift_url" label="Lien-cadeau Giftbit *" outlined dense
placeholder="https://gft.link/..."
:rules="[v => !!v || 'requis', v => /^https?:\/\//.test(v) || 'doit commencer par http:// ou https://']" />
<q-input v-model="manualRow.civic_address" label="Adresse civique (pour {{description}})" outlined dense
placeholder="25 Rue des Hirondelles" />
<div class="row q-col-gutter-sm">
<q-input v-model="manualRow.city" label="Ville" outlined dense
placeholder="Ste-Clotilde" class="col-12 col-sm-7" />
<q-input v-model="manualRow.postal_code" label="Code postal" outlined dense
placeholder="J0L 1W0" class="col-12 col-sm-5" />
</div>
<div class="row q-col-gutter-sm">
<q-select v-model="manualRow.language" :options="languageOptions" emit-value map-options
label="Langue du template" outlined dense class="col-12 col-sm-6" />
<q-input v-model="manualRow.phone" label="Téléphone (optionnel)" outlined dense class="col-12 col-sm-6" />
</div>
<div class="row q-col-gutter-sm">
<q-input v-model="manualRow.amount" label="Montant affiché dans le courriel" outlined dense
:placeholder="`défaut: ${params.amount}`" class="col-12 col-sm-6">
<q-tooltip><span v-pre>Texte qui apparaîtra à la place de la variable {{amount}}. Laisse vide pour utiliser le montant de la campagne.</span></q-tooltip>
</q-input>
<q-input v-model.number="manualRow.gift_value_cents" type="number"
label="Valeur en cents (rapport)" outlined dense placeholder="5000"
class="col-12 col-sm-6" /></div>
<div class="text-caption text-grey-7 q-mt-xs">
<q-icon name="info" size="14px" /> Ville et code postal ne s'affichent pas dans le courriel —
ils servent à éviter une confusion entre deux clients du même nom dans le tableau ci-dessous
et dans le rapport CSV.
</div>
<div class="text-caption text-grey-7 q-mt-xs">
<q-icon name="info" size="14px" /> Aucun match ERPNext n'est tenté.
<span v-pre>Seule l'adresse civique apparaît dans le courriel (variable <code>{{description}}</code>).</span>
</div>
<div class="row q-mt-md">
<q-space />
<q-btn flat label="Annuler" v-close-popup class="q-mr-sm" />
<q-btn unelevated color="primary" type="submit" icon="add" label="Ajouter" />
</div>
</q-form>
</q-card-section>
</q-card>
</q-dialog>
<!-- Preview dialog — renders the actual template with the first sendable
recipient's data + the campaign params. Lets the user verify the
visual + content WITHOUT firing any emails. Toggleable FR/EN since
a mixed-language campaign would send both templates. -->
<q-dialog v-model="previewOpen" maximized persistent>
<q-card class="bg-grey-2">
<q-toolbar class="bg-white" style="border-bottom:1px solid #e5e7eb">
<q-icon name="visibility" class="q-mr-sm" />
<q-toolbar-title>
Aperçu du courriel
<span v-if="previewRecipient" class="text-caption text-grey-7">
· destinataire #{{ previewRecipient.row_index }} {{ previewRecipient.firstname }} {{ previewRecipient.lastname }}
</span>
</q-toolbar-title>
<q-btn-toggle v-model="previewLang" :options="[{label:'🇫🇷 FR', value:'fr'},{label:'🇺🇸 EN', value:'en'}]"
dense unelevated toggle-color="primary" @update:model-value="renderPreview" />
<q-btn flat icon="open_in_new" :href="editorHref" target="_blank" class="q-mx-sm">
<q-tooltip>Éditer dans un nouvel onglet</q-tooltip>
</q-btn>
<q-btn flat dense round icon="close" @click="previewOpen = false" />
</q-toolbar>
<q-banner v-if="previewLoading" class="bg-blue-1 text-blue-9">
<q-spinner size="sm" /> Rendu en cours…
</q-banner>
<q-card-section class="q-pa-md" style="height: calc(100vh - 60px); overflow:hidden">
<iframe :srcdoc="previewHtmlContent" style="width:100%; height:100%; border:1px solid #e5e7eb; background:#fff;"></iframe>
</q-card-section>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { parseCsvs, createCampaign, sendCampaign, previewTemplate, listTemplates } 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 toi, de la part de TARGO',
from: 'TARGO <support@targointernet.com>',
expiry: '',
throttle_ms: 600,
multi: 'first',
// Internal wrapper-URL expiry. Independent of Giftbit's own expiry —
// after this many days the /g/<token> link returns the expired page,
// freeing the underlying Giftbit gift_url for reassignment to another
// customer in a new campaign.
gift_expiry_days: 90,
// Per-language template selection. Defaults match the canonical templates;
// operator can switch to a variant (e.g. seasonal) per campaign.
template_fr: 'gift-email-fr',
template_en: 'gift-email-en',
})
// Template dropdowns. Lists EVERY editable gift-email-* template in both
// dropdowns, but sorts so the language-matching ones (-fr / -en suffix)
// appear at the top — operator instinct will pick those first while still
// allowing a non-conventionally-named template to be chosen for testing
// or one-off campaigns.
//
// Refreshed on mount AND every time the dropdown popup opens (q-select
// @popup-show), so creating a new template in another tab and switching
// back picks it up without needing a page reload.
const loadingTemplates = ref(false)
const frTemplateOptions = ref([{ label: 'gift-email-fr', value: 'gift-email-fr' }])
const enTemplateOptions = ref([{ label: 'gift-email-en', value: 'gift-email-en' }])
async function loadTemplateLists () {
loadingTemplates.value = true
try {
const tpls = await listTemplates()
const buildOption = (t, preferred) => ({
label: preferred ? `${t.name}` : `${t.name} · sans suffixe de langue`,
value: t.name,
size: t.size || 0,
})
const fr = [], en = []
for (const t of tpls) {
if (!t?.name || typeof t.name !== 'string') continue
if (t.name.endsWith('-fr')) fr.push(buildOption(t, true))
else if (t.name.endsWith('-en')) en.push(buildOption(t, true))
// Templates without -fr/-en suffix (e.g. gift-email-test) are pushed
// to BOTH lists as fallback choices — the operator can still pick
// them for either language if they're testing a draft.
else {
fr.push(buildOption(t, false))
en.push(buildOption(t, false))
}
}
// Sort: preferred (no "sans suffixe" tag) first, then alphabetical
const byPriority = (a, b) => {
const aPref = !a.label.includes('sans suffixe')
const bPref = !b.label.includes('sans suffixe')
if (aPref !== bPref) return aPref ? -1 : 1
return a.value.localeCompare(b.value)
}
fr.sort(byPriority); en.sort(byPriority)
if (fr.length) frTemplateOptions.value = fr
if (en.length) enTemplateOptions.value = en
} catch (e) {
$q.notify({ type: 'warning', message: 'Liste des templates non chargée: ' + e.message })
} finally {
loadingTemplates.value = false
}
}
onMounted(loadTemplateLists)
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' },
]
// expiryPreset reflects the current params.gift_expiry_days as one of the
// preset buttons, or 'custom' when the value is non-standard. Two-way:
// clicking a preset writes back to params.gift_expiry_days.
const EXPIRY_PRESETS = [15, 30, 60, 90, 180]
const expiryPreset = computed(() => {
const v = Number(params.value.gift_expiry_days)
return EXPIRY_PRESETS.includes(v) ? v : 'custom'
})
function setExpiryPreset (val) {
if (val === 'custom') {
// Don't reset — let the operator continue typing in the custom input.
// If they were on a preset and switched to custom, keep that value as starting point.
return
}
params.value.gift_expiry_days = val
}
const parsing = ref(false)
const sending = ref(false)
const recipients = ref([])
const unpairedContacts = ref([])
const unusedGifts = ref([])
// Map CSV parse skip counters surfaced from the hub so the operator can see
// exactly which rows didn't make it into the pairing (no email, duplicates,
// multi-skip).
const parseSkipped = ref(null)
// The actual rows dropped (up to 200) — each with the raw source columns
// + reason — for the inspectable expansion-item below the imbalance banner.
const parseSkippedRows = 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: 'city', label: 'Ville', field: r => r.city || r.postal_code || '', align: 'left' },
{ name: 'gift_url', label: 'Lien-cadeau', field: 'gift_url', align: 'left' },
{ name: 'language', label: 'Langue', field: 'language', align: 'left' },
{ name: 'match', label: 'Match', 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' },
]
// Columns for the "dropped at parsing" expansion table. Reads from the raw
// CSV columns (raw_full_name, raw_email, etc.) since the row never made it
// through the contact-building pipeline.
const skippedRowsColumns = [
{ name: 'source_row', label: 'Ligne #', field: 'source_row', align: 'left' },
{ name: 'reason', label: 'Raison', field: 'reason', align: 'left' },
{ name: 'raw_full_name', label: 'Nom au CSV', field: 'raw_full_name', align: 'left' },
{ name: 'raw_email', label: 'Email au CSV', field: 'raw_email', align: 'left' },
{ name: 'raw_address', label: 'Adresse', field: 'raw_address', align: 'left' },
{ name: 'raw_postal', label: 'Code postal', field: 'raw_postal', align: 'left' },
{ name: 'actions', label: '', field: '', align: 'right' },
]
function reasonColor (reason) {
return {
no_email: 'red-7',
duplicate: 'orange-7',
multi_skip: 'amber-8',
}[reason] || 'grey-7'
}
function reasonLabel (reason) {
return {
no_email: 'Sans email valide',
duplicate: 'Email en double',
multi_skip: 'Couple ignoré (réglage multi)',
}[reason] || reason
}
// Open the manual-add dialog pre-filled with the dropped row's data so the
// operator can recover it in one click. The duplicate/multi_skip cases are
// the ones worth recovering (the email IS there — we just intentionally
// skipped it); no_email cases can't be recovered without an email address.
function prefillManualFromSkipped (row) {
const [first, ...rest] = (row.raw_full_name || '').trim().split(/\s+/)
manualRow.value = {
...emptyManualRow(),
firstname: first || '',
lastname: rest.join(' ') || '',
email: (row.raw_email || '').split(/[;,\s]+/).filter(e => e.includes('@'))[0] || '',
phone: row.raw_phone || '',
civic_address: row.raw_address || '',
postal_code: row.raw_postal || '',
}
manualOpen.value = true
}
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)
// Names that the auto-cleaner couldn't confidently fix. Heuristic warnings
// from the backend (digit in name, two names possibly stuck together, etc.).
// User should glance at these before sending.
const namesNeedingReview = computed(() =>
recipients.value.filter(r => r.name_warnings?.firstname || r.name_warnings?.lastname).length
)
const namesAutoCorrected = computed(() =>
recipients.value.filter(r => r.cleaned_changed).length
)
// FR / EN breakdown of the sendable recipients — useful preview before launch
// so the user knows which template will actually be used and how many.
const langBreakdown = computed(() => {
const counts = {}
for (const r of recipients.value) {
if (r.excluded || !r.gift_url) continue
const lang = (r.language || 'fr').toLowerCase()
counts[lang] = (counts[lang] || 0) + 1
}
const parts = Object.entries(counts).map(([l, n]) => `${n} × ${l.toUpperCase()}`)
return parts.length ? parts.join(', ') : '—'
})
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 ? '…' : '')
}
// ── Preview dialog ──────────────────────────────────────────────────────────
// Renders the email through the hub's /campaigns/templates/:name/preview
// endpoint, using the first sendable recipient's data + the current campaign
// params. Non-destructive — no emails are fired by this action.
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewHtmlContent = ref('')
const previewLang = ref('fr')
const previewRecipient = ref(null)
// First recipient that's actually going to be sent — used as the preview
// sample so the user sees real data, not synthetic placeholders.
const firstPreviewable = computed(() =>
recipients.value.find(r => !r.excluded && r.gift_url) || null
)
// Link to the template editor for the relevant language. Always opens
// in a new tab so the user's in-progress wizard state is preserved.
const editorHref = computed(() =>
`/ops/#/campaigns/templates/gift-email-${previewLang.value}`
)
async function openPreview () {
const r = firstPreviewable.value
if (!r) return
previewRecipient.value = r
// Default preview language to the recipient's actual language so the user
// first sees what THIS recipient will receive
previewLang.value = (r.language || 'fr').toLowerCase().split('-')[0]
previewOpen.value = true
await renderPreview()
}
async function renderPreview () {
if (!previewRecipient.value) return
previewLoading.value = true
try {
const r = previewRecipient.value
const vars = {
firstname: r.firstname || (previewLang.value === 'en' ? 'dear customer' : 'cher client'),
lastname: r.lastname || '',
email: r.email,
description: r.civic_address || '',
gift_url: r.gift_url,
amount: params.value.amount,
expiry: params.value.expiry,
commitment_months: params.value.commitment_months,
year: new Date().getFullYear(),
}
const res = await previewTemplate(`gift-email-${previewLang.value}`, { vars })
previewHtmlContent.value = res.rendered || ''
} catch (e) {
$q.notify({ type: 'negative', message: 'Aperçu impossible: ' + e.message })
} finally {
previewLoading.value = false
}
}
// Wrapper around launchSend that confirms one last time before firing. The
// Step 3 page is already a "confirmation step", but this dialog adds one
// final friction so accidental clicks don't fire 200 emails.
function confirmAndLaunch () {
$q.dialog({
title: 'Envoyer maintenant ?',
message: `Cette action enverra <strong>${sendableCount.value} courriel(s)</strong>
via Mailjet immédiatement. Pas annulable une fois démarré.`,
html: true,
persistent: true,
ok: { label: 'Oui, envoyer', color: 'negative', icon: 'send', unelevated: true },
cancel: { label: 'Annuler', flat: true },
}).onOk(() => {
launchSend()
})
}
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 || []
parseSkipped.value = r.skipped || null
parseSkippedRows.value = r.skipped_rows || []
step.value = 2
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur de parsing: ' + e.message })
} finally {
parsing.value = false
}
}
// Skip CSV parsing entirely — empty recipients list, user adds rows manually
// via the "Ajouter manuellement" button on Step 2. The Suivant button is
// disabled if no rows, so the user can't accidentally proceed with nothing.
function goManual () {
recipients.value = []
unpairedContacts.value = []
unusedGifts.value = []
step.value = 2
// Open the dialog immediately — the most likely next action is adding a row
openManualDialog()
}
// ── Manual recipient entry ────────────────────────────────────────────────
// Lets the user add a recipient by typing the fields directly instead of
// importing from CSV. Useful for: small ad-hoc gifts, replacement sends after
// a bounce, internal QA test sends, or topping up an existing CSV import
// with a missed contact. No ERPNext matching is attempted on these rows.
const manualOpen = ref(false)
const languageOptions = [
{ label: '🇫🇷 Français', value: 'fr' },
{ label: '🇺🇸 English', value: 'en' },
]
function emptyManualRow () {
return {
firstname: '', lastname: '', email: '', phone: '',
civic_address: '', city: '', postal_code: '', language: 'fr',
gift_url: '', gift_value_cents: null, amount: '',
}
}
const manualRow = ref(emptyManualRow())
function openManualDialog () {
manualRow.value = emptyManualRow()
manualOpen.value = true
}
function submitManualRow () {
// Determine next row_index. CSV-imported rows start at 1; manuals continue
// the sequence so #N reads naturally regardless of source.
const maxIdx = recipients.value.reduce((m, r) => Math.max(m, r.row_index || 0), 0)
recipients.value.push({
row_index: maxIdx + 1,
firstname: (manualRow.value.firstname || '').trim(),
lastname: (manualRow.value.lastname || '').trim(),
email: (manualRow.value.email || '').trim().toLowerCase(),
phone: (manualRow.value.phone || '').trim(),
civic_address: (manualRow.value.civic_address || '').trim(),
city: (manualRow.value.city || '').trim(),
postal_code: (manualRow.value.postal_code || '').trim().toUpperCase(),
language: manualRow.value.language || 'fr',
gift_url: (manualRow.value.gift_url || '').trim(),
gift_value_cents: manualRow.value.gift_value_cents || null,
// Per-recipient amount override. Empty string → falls back to campaign
// params.amount in the worker. Useful for manuals on a mixed-amount campaign.
amount: (manualRow.value.amount || '').trim() || null,
giftbit_uuid: null,
// Flag for downstream code + table display so a "manuel" chip can be shown
// and the match-method column doesn't read "non lié" misleadingly.
manual: true,
match_method: 'manuel',
customer_id: null,
status: 'pending',
excluded: false,
})
manualOpen.value = false
$q.notify({ type: 'positive', message: 'Destinataire ajouté' })
}
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>