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>
This commit is contained in:
louispaulb 2026-05-22 11:55:48 -04:00
parent 5b5df954c1
commit d5ee57acf2
2 changed files with 115 additions and 6 deletions

View File

@ -373,6 +373,41 @@
</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
@ -648,6 +683,9 @@ const unusedGifts = ref([])
// 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.
@ -677,6 +715,52 @@ const unpairedColumns = [
{ 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)
@ -848,6 +932,7 @@ async function goPreview () {
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 })

View File

@ -391,20 +391,34 @@ function parseMapCsv (text, multi = 'first') {
const contacts = []
const seen = new Set()
let skippedNoEmail = 0, skippedNoName = 0, skippedDuplicate = 0, skippedMultiSkip = 0
// Capture the actual rows that were dropped so the wizard can show
// operators exactly WHO got lost, not just a count. Each entry includes
// the reason and just enough source-CSV columns to identify the human.
const skippedRows = []
const snapshot = (row, reason, idx) => ({
source_row: idx + 2, // +1 for 0-index, +1 for header line — easier to cross-reference in Excel
reason,
raw_full_name: (row['nom au compte'] || row["nom à l'adresse"] || '').trim(),
raw_email: (row['email au compte'] || row["email à l'adresse"] || '').trim(),
raw_phone: (row['telephone au compte'] || row["telephone à l'adresse"] || '').trim(),
raw_address: (row['adresse dans F'] || '').trim(),
raw_postal: (row['code postal au compte'] || row["code postal à l'adresse"] || '').trim(),
})
for (const r of rows) {
for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
const r = rows[rowIdx]
// Pull emails from either source column
let rawEmails = (r['email au compte'] || r["email à l'adresse"] || '').trim()
if (!rawEmails) { skippedNoEmail++; continue }
if (!rawEmails) { skippedNoEmail++; skippedRows.push(snapshot(r, 'no_email', rowIdx)); continue }
const emails = rawEmails.split(EMAIL_SPLIT)
.map(e => normalizeEmail(e))
.filter(e => e.includes('@') && e.split('@')[1]?.includes('.'))
if (!emails.length) { skippedNoEmail++; continue }
if (!emails.length) { skippedNoEmail++; skippedRows.push(snapshot(r, 'no_email', rowIdx)); continue }
const sendEmails = multi === 'split' ? emails
: multi === 'skip' ? (emails.length > 1 ? [] : emails)
: emails.slice(0, 1)
if (!sendEmails.length) { skippedMultiSkip++; continue }
if (!sendEmails.length) { skippedMultiSkip++; skippedRows.push(snapshot(r, 'multi_skip', rowIdx)); continue }
// We used to skip rows without a name here. That dropped the contact AND
// wasted its paired Giftbit shortlink — the worker already defaults
@ -434,7 +448,13 @@ function parseMapCsv (text, multi = 'first') {
const phone = normalizePhone(r['telephone au compte'] || r["telephone à l'adresse"] || '')
for (const em of sendEmails) {
if (seen.has(em)) { skippedDuplicate++; continue }
if (seen.has(em)) {
skippedDuplicate++
// Capture the duplicate row with its specific email — useful when one
// raw row had multiple emails and only some were dupes (multi='split').
skippedRows.push({ ...snapshot(r, 'duplicate', rowIdx), raw_email: em })
continue
}
seen.add(em)
contacts.push({
firstname, lastname,
@ -457,6 +477,9 @@ function parseMapCsv (text, multi = 'first') {
multi_skip: skippedMultiSkip,
total_rows: rows.length,
},
// Cap at 200 to keep response size reasonable on very dirty exports;
// the count in `skipped` always reflects the true total.
skipped_rows: skippedRows.slice(0, 200),
}
}
@ -1308,7 +1331,7 @@ async function handle (req, res, method, path) {
if (!map_csv || !giftbit_csv) {
return json(res, 400, { error: 'map_csv and giftbit_csv required' })
}
const { contacts, skipped } = parseMapCsv(map_csv, multi || 'first')
const { contacts, skipped, skipped_rows } = parseMapCsv(map_csv, multi || 'first')
const gifts = parseGiftbitCsv(giftbit_csv)
// Match contacts ↔ gifts (by row order, fallback to email when Giftbit
@ -1352,6 +1375,7 @@ async function handle (req, res, method, path) {
leftover_gifts: unused_gifts.length,
leftover_contacts: unpaired_contacts.length,
skipped,
skipped_rows,
})
}