diff --git a/frontend/src/lib/utils/csv.js b/frontend/src/lib/utils/csv.js index 48c475c..46b30e3 100644 --- a/frontend/src/lib/utils/csv.js +++ b/frontend/src/lib/utils/csv.js @@ -3,17 +3,26 @@ import papaparse from 'papaparse'; /** * Parse CSV file to recipients * @param {File} file - CSV file - * @returns {Promise>} + * @returns {Promise<{recipients: Array<*>, skipped: Array<{line: number, reason: string, row: object}>}>} **/ export const parseCSVToRecipients = async (file) => { const p = new Promise((resolve, reject) => { const recipients = {}; + const skipped = []; papaparse.parse(file, { header: true, skipEmptyLines: true, complete: (results) => { - if (results.errors) { + if (results.errors && results.errors.length > 0) { console.info('CSV import errors', results.errors); + // track parsing errors + results.errors.forEach((error) => { + skipped.push({ + line: error.row + 2, // +1 for header, +1 for 0-index + reason: `parse error: ${error.message}`, + row: error.row + }); + }); } if (!results.data) { reject('No data found in CSV file'); @@ -26,9 +35,23 @@ export const parseCSVToRecipients = async (file) => { fieldsMap[field.toLowerCase()] = field; } - results.data.forEach((row) => { + results.data.forEach((row, index) => { const email = row[fieldsMap['email']]; if (!email) { + skipped.push({ + line: index + 2, // +1 for header, +1 for 0-index + reason: 'missing email', + row: row + }); + return; + } + // check if email already exists in this import (duplicate within file) + if (recipients[email]) { + skipped.push({ + line: index + 2, + reason: `duplicate email in file (first occurrence at line ${recipients[email]._line})`, + row: row + }); return; } recipients[email] = { @@ -41,10 +64,18 @@ export const parseCSVToRecipients = async (file) => { department: row[fieldsMap['department']] ?? null, city: row[fieldsMap['city']] ?? null, country: row[fieldsMap['country']] ?? null, - misc: row[fieldsMap['misc']] ?? null + misc: row[fieldsMap['misc']] ?? null, + _line: index + 2 // track line number for duplicate detection }; }); - resolve(Object.values(recipients)); + + // remove internal _line property before returning + const recipientsList = Object.values(recipients).map((r) => { + const { _line, ...recipient } = r; + return recipient; + }); + + resolve({ recipients: recipientsList, skipped }); } }); }); diff --git a/frontend/src/routes/recipient/+page.svelte b/frontend/src/routes/recipient/+page.svelte index 7d71d0c..16e1d6c 100644 --- a/frontend/src/routes/recipient/+page.svelte +++ b/frontend/src/routes/recipient/+page.svelte @@ -62,6 +62,7 @@ recipients: [], ignoreOverwriteEmptyFields: true }; + let csvSkippedRows = []; const tableImportParams = newTableParams({ sortBy: 'email' }); let selectedRecipientsImportPaginatedChunk = []; let isImportModalVisible = false; @@ -209,12 +210,12 @@ importModalError = res.error; return; } - addToast('Recipients imported to group', 'Success'); + addToast('Recipients imported', 'Success'); closeImportModal(); refreshRecipients(); } catch (err) { - addToast('Failed to import recipients to group', 'Error'); - console.error('failed to import recipients to group', err); + addToast('Failed to import recipients', 'Error'); + console.error('failed to import recipients', err); } finally { isSubmitting = false; } @@ -228,9 +229,16 @@ showIsLoading(); for (let i = 0; i < files.length; i++) { const file = files[i]; - const recipientsForImport = await parseCSVToRecipients(file); + const result = await parseCSVToRecipients(file); + + // track skipped rows + if (result.skipped && result.skipped.length > 0) { + csvSkippedRows = csvSkippedRows.concat(result.skipped); + console.info(`CSV import: ${result.skipped.length} rows skipped`, result.skipped); + } + importFormValues.recipients = importFormValues.recipients.concat( - recipientsForImport.filter( + result.recipients.filter( (recipient) => !importFormValues.recipients.some( (existingRecipient) => existingRecipient.email === recipient.email @@ -238,6 +246,17 @@ ) ); refreshImportsPaginated(); + + // show info about skipped rows + if (result.skipped && result.skipped.length > 0) { + const skippedMsg = result.skipped + .slice(0, 3) + .map((s) => `Line ${s.line}: ${s.reason}`) + .join('\n'); + const remaining = + result.skipped.length > 3 ? `\n... and ${result.skipped.length - 3} more` : ''; + importModalError = `CSV rows skipped:\n${skippedMsg}${remaining}\n\nReview the data before importing.`; + } } } catch (e) { importModalError = e; @@ -268,6 +287,8 @@ }; const openImportModal = () => { + csvSkippedRows = []; + importModalError = ''; isImportModalVisible = true; }; diff --git a/frontend/src/routes/recipient/group/[id]/+page.svelte b/frontend/src/routes/recipient/group/[id]/+page.svelte index e2fb6c3..4f6cfa4 100644 --- a/frontend/src/routes/recipient/group/[id]/+page.svelte +++ b/frontend/src/routes/recipient/group/[id]/+page.svelte @@ -61,6 +61,7 @@ recipients: [], ignoreOverwriteEmptyFields: true }; + let csvSkippedRows = []; const tableImportParams = newTableParams({ sortBy: 'email' }); let selectedRecipientsImportPaginatedChunk = []; let isImportModalVisible = false; @@ -281,9 +282,15 @@ try { for (let i = 0; i < files.length; i++) { const file = files[i]; - const recipientsForImport = await parseCSVToRecipients(file); + const result = await parseCSVToRecipients(file); + + // track skipped rows + if (result.skipped && result.skipped.length > 0) { + console.info(`CSV import: ${result.skipped.length} rows skipped`, result.skipped); + } + importFormValues.recipients = importFormValues.recipients.concat( - recipientsForImport.filter( + result.recipients.filter( (recipient) => !importFormValues.recipients.some( (existingRecipient) => existingRecipient.email === recipient.email @@ -291,6 +298,17 @@ ) ); refreshImportsPaginated(); + + // show info about skipped rows + if (result.skipped && result.skipped.length > 0) { + const skippedMsg = result.skipped + .slice(0, 3) + .map((s) => `Line ${s.line}: ${s.reason}`) + .join('\n'); + const remaining = + result.skipped.length > 3 ? `\n... and ${result.skipped.length - 3} more` : ''; + importError = `CSV rows skipped:\n${skippedMsg}${remaining}\n\nReview the data below before importing.`; + } } } catch (e) { importError = e; @@ -333,6 +351,8 @@ }; const openImportModal = () => { + csvSkippedRows = []; + importError = ''; isImportModalVisible = true; };