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-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 })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user