fix(ops/campaigns): correct row counts in Step 1 — Link Order CSV had no header

The Step 1 file-upload widgets displayed `(newlines) - 1` for both CSVs,
assuming both files have a header row to discount. This breaks for the
Giftbit Link Order export which is headerless (one URL per line): a
3-URL file was showing "2 cartes-cadeaux" because the parser ate URL #1
as a fake header.

The backend parser was already correct (detects Link Order vs Campaign
format by inspecting the first line). The bug was UI-only — the count
display reused the same arithmetic for both formats.

Fix: introduce countMapRows / countGiftRows helpers that mirror the
backend's format detection. Map CSV subtracts 2 (preamble + header).
Gift CSV subtracts 0 for Link Order (headerless) or 1 for Campaign
export (with header). Plus a "(format: Link Order)" hint next to the
count so the user sees which detection path was taken.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-05-21 20:27:22 -04:00
parent ff629a6a85
commit 0186a7318e

View File

@ -22,7 +22,7 @@
<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">
{{ Math.max(0, (mapPreview.match(/\n/g)||[]).length - 1) }} lignes lues
{{ countMapRows(mapPreview) }} lignes de contacts (préambule + header retirés)
</div>
</q-card-section>
</q-card>
@ -40,7 +40,8 @@
<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">
{{ Math.max(0, (giftPreview.match(/\n/g)||[]).length - 1) }} cartes-cadeaux
{{ countGiftRows(giftPreview) }} cartes-cadeaux
<span v-if="giftFormatHint" class="text-grey-6">(format: {{ giftFormatHint }})</span>
</div>
</q-card-section>
</q-card>
@ -234,6 +235,44 @@ function readFile (file) {
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