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:
parent
5b5df954c1
commit
d5ee57acf2
|
|
@ -373,6 +373,41 @@
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-expansion-item>
|
</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,
|
<!-- Action row: preview + edit template are quick-access utilities,
|
||||||
both non-destructive. The primary action is "Continuer" which
|
both non-destructive. The primary action is "Continuer" which
|
||||||
moves to Step 3 (still NOT the send — Step 3 has its own
|
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,
|
// exactly which rows didn't make it into the pairing (no email, duplicates,
|
||||||
// multi-skip).
|
// multi-skip).
|
||||||
const parseSkipped = ref(null)
|
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
|
// 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.
|
// 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' },
|
{ 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 matchedCount = computed(() => recipients.value.filter(r => r.customer_id).length)
|
||||||
const unmatchedCount = 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)
|
const excludedCount = computed(() => recipients.value.filter(r => r.excluded).length)
|
||||||
|
|
@ -848,6 +932,7 @@ async function goPreview () {
|
||||||
unpairedContacts.value = r.unpaired_contacts || []
|
unpairedContacts.value = r.unpaired_contacts || []
|
||||||
unusedGifts.value = r.unused_gifts || []
|
unusedGifts.value = r.unused_gifts || []
|
||||||
parseSkipped.value = r.skipped || null
|
parseSkipped.value = r.skipped || null
|
||||||
|
parseSkippedRows.value = r.skipped_rows || []
|
||||||
step.value = 2
|
step.value = 2
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$q.notify({ type: 'negative', message: 'Erreur de parsing: ' + e.message })
|
$q.notify({ type: 'negative', message: 'Erreur de parsing: ' + e.message })
|
||||||
|
|
|
||||||
|
|
@ -391,20 +391,34 @@ function parseMapCsv (text, multi = 'first') {
|
||||||
const contacts = []
|
const contacts = []
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
let skippedNoEmail = 0, skippedNoName = 0, skippedDuplicate = 0, skippedMultiSkip = 0
|
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
|
// Pull emails from either source column
|
||||||
let rawEmails = (r['email au compte'] || r["email à l'adresse"] || '').trim()
|
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)
|
const emails = rawEmails.split(EMAIL_SPLIT)
|
||||||
.map(e => normalizeEmail(e))
|
.map(e => normalizeEmail(e))
|
||||||
.filter(e => e.includes('@') && e.split('@')[1]?.includes('.'))
|
.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
|
const sendEmails = multi === 'split' ? emails
|
||||||
: multi === 'skip' ? (emails.length > 1 ? [] : emails)
|
: multi === 'skip' ? (emails.length > 1 ? [] : emails)
|
||||||
: emails.slice(0, 1)
|
: 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
|
// We used to skip rows without a name here. That dropped the contact AND
|
||||||
// wasted its paired Giftbit shortlink — the worker already defaults
|
// 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"] || '')
|
const phone = normalizePhone(r['telephone au compte'] || r["telephone à l'adresse"] || '')
|
||||||
|
|
||||||
for (const em of sendEmails) {
|
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)
|
seen.add(em)
|
||||||
contacts.push({
|
contacts.push({
|
||||||
firstname, lastname,
|
firstname, lastname,
|
||||||
|
|
@ -457,6 +477,9 @@ function parseMapCsv (text, multi = 'first') {
|
||||||
multi_skip: skippedMultiSkip,
|
multi_skip: skippedMultiSkip,
|
||||||
total_rows: rows.length,
|
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) {
|
if (!map_csv || !giftbit_csv) {
|
||||||
return json(res, 400, { error: 'map_csv and giftbit_csv required' })
|
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)
|
const gifts = parseGiftbitCsv(giftbit_csv)
|
||||||
|
|
||||||
// Match contacts ↔ gifts (by row order, fallback to email when Giftbit
|
// 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_gifts: unused_gifts.length,
|
||||||
leftover_contacts: unpaired_contacts.length,
|
leftover_contacts: unpaired_contacts.length,
|
||||||
skipped,
|
skipped,
|
||||||
|
skipped_rows,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user