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>
1029 lines
54 KiB
Vue
1029 lines
54 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>
|
||
<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-<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" />
|
||
<!-- 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/<token></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>
|