feat: bulk ASIN selection via space-separated paste in filters

Allow pasting multiple ASINs separated by spaces in any MultiSelectDropdown
search field and pressing Enter to auto-select all matching options.
Also switch ads CSV/Excel parsing to header-based column mapping for
robustness across different file formats.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Vidal Wolf
2026-02-25 14:01:11 +01:00
co-authored by Claude Opus 4.6
parent 69eafe8335
commit 487b8e105e
3 changed files with 47 additions and 65 deletions
+1
View File
@@ -26,3 +26,4 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.vercel
+14 -1
View File
@@ -181,7 +181,20 @@ const MultiSelectDropdown: React.FC<MultiSelectDropdownProps> = ({ label, select
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
if (filteredOptions.length > 0) { // Check if input contains multiple space-separated terms (bulk paste)
const terms = searchTerm.trim().split(/\s+/).filter(Boolean);
if (terms.length > 1) {
// Bulk select: match each term against options (case-insensitive)
const termsLower = terms.map(t => t.toLowerCase());
const matched = options.filter(opt =>
termsLower.some(t => opt.toLowerCase() === t || opt.toLowerCase().includes(t))
);
if (matched.length > 0) {
onChange(Array.from(new Set([...selected, ...matched])));
}
setIsOpen(false);
setSearchTerm('');
} else if (filteredOptions.length > 0) {
const allSelected = filteredOptions.every(opt => selected.includes(opt)); const allSelected = filteredOptions.every(opt => selected.includes(opt));
if (allSelected) { if (allSelected) {
// Deselect all filtered // Deselect all filtered
+32 -64
View File
@@ -417,7 +417,7 @@ export const processAdsCSV = (file: File): Promise<AdsRecord[]> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// @ts-ignore // @ts-ignore
Papa.parse(file, { Papa.parse(file, {
header: false, // Index-based mapping header: true,
skipEmptyLines: true, skipEmptyLines: true,
complete: (results: any) => { complete: (results: any) => {
try { try {
@@ -427,40 +427,22 @@ export const processAdsCSV = (file: File): Promise<AdsRecord[]> => {
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
const row = rows[i]; const row = rows[i];
if (!Array.isArray(row) || row.length < 12) continue; if (!row || Object.keys(row).length < 5) continue;
// Check header row (Column A: Customer or Country) const countryRaw = getColumnValue(row, ['country', 'marketplace', 'portfolio', 'customer', 'kunde']);
const c0 = String(row[0]).trim().toLowerCase(); const weekRaw = getColumnValue(row, ['week', 'woche', 'semana']);
if (c0.includes('customer') || c0.includes('country') || c0.includes('marketplace')) continue; const asin = getColumnValue(row, ['asin']);
const costRaw = getColumnValue(row, ['cost', 'spend', 'ausgaben', 'gasto', 'coste', 'ad spend']);
const clicksRaw = getColumnValue(row, ['clicks', 'klicks', 'clics']);
const impressionsRaw = getColumnValue(row, ['impressions', 'impresiones', 'imp']);
const cpcRaw = getColumnValue(row, ['cpc', 'cost-per-click', 'coste por clic']);
const ctrRaw = getColumnValue(row, ['ctr', 'click-through rate', 'click-through-rate', 'click through rate']);
const acosRaw = getColumnValue(row, ['acos', 'advertising cost of sales', 'aCOS']);
const conversionsRaw = getColumnValue(row, ['conversions', 'konversionen', 'orders', 'pedidos', 'total orders']);
const unitsRaw = getColumnValue(row, ['units', 'einheiten', 'unidades', 'units sold', 'total units']);
const salesRaw = getColumnValue(row, ['sales', 'umsatz', 'ventas', 'ad sales', 'total sales', 'sales (30d)']);
// Column mapping for CSV (same as Excel): if (!asin || !countryRaw || weekRaw === undefined || weekRaw === '') continue;
// A (0): Country
// B (1): Week
// C (2): ASIN
// D (3): Cost
// E (4): Clicks
// F (5): Impressions
// G (6): CPC
// H (7): CTR %
// I (8): ACOS %
// J (9): Conversions (30d)
// K (10): Units (30d)
// L (11): Sales (30d)
const countryRaw = row[0];
const weekRaw = row[1];
const asin = row[2];
const costRaw = row[3];
const clicksRaw = row[4];
const impressionsRaw = row[5];
const cpcRaw = row[6];
const ctrRaw = row[7];
const acosRaw = row[8];
const conversionsRaw = row[9];
const unitsRaw = row[10];
const salesRaw = row[11];
if (!asin || !countryRaw || weekRaw === undefined) continue;
const weekNum = parseInt(String(weekRaw)); const weekNum = parseInt(String(weekRaw));
if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue; if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue;
@@ -511,42 +493,28 @@ export const processAdsExcel = async (fileOrBuffer: File | ArrayBuffer): Promise
} }
const worksheet = workbook.Sheets[sheetName]; const worksheet = workbook.Sheets[sheetName];
// Use header: 1 to get array of arrays (row-based) // Use default format (header mapping) instead of array rows
const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: "" }); const jsonData: any[] = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
console.log(`Processing sheet ${sheetName}: ${jsonData.length} rows`); console.log(`Processing sheet ${sheetName}: ${jsonData.length} rows`);
// Skip header row (index 0), process data rows // No need to skip index 0 since headers are mapped as keys automatically
for (let i = 1; i < jsonData.length; i++) { for (let i = 0; i < jsonData.length; i++) {
const row = jsonData[i]; const row = jsonData[i];
if (!row || row.length < 12) continue; if (!row || Object.keys(row).length < 5) continue;
// Column mapping for Ads Weekly.xlsx: const countryRaw = getColumnValue(row, ['country', 'marketplace', 'portfolio', 'customer', 'kunde']);
// A (0): Country const weekRaw = getColumnValue(row, ['week', 'woche', 'semana']);
// B (1): Week const asin = getColumnValue(row, ['asin']);
// C (2): ASIN const costRaw = getColumnValue(row, ['cost', 'spend', 'ausgaben', 'gasto', 'coste', 'ad spend']);
// D (3): Cost const clicksRaw = getColumnValue(row, ['clicks', 'klicks', 'clics']);
// E (4): Clicks const impressionsRaw = getColumnValue(row, ['impressions', 'impresiones', 'imp']);
// F (5): Impressions const cpcRaw = getColumnValue(row, ['cpc', 'cost-per-click', 'coste por clic']);
// G (6): CPC const ctrRaw = getColumnValue(row, ['ctr', 'click-through rate', 'click-through-rate', 'click through rate']);
// H (7): CTR % const acosRaw = getColumnValue(row, ['acos', 'advertising cost of sales', 'aCOS']);
// I (8): ACOS % const conversionsRaw = getColumnValue(row, ['conversions', 'konversionen', 'orders', 'pedidos', 'total orders']);
// J (9): Conversions (30d) const unitsRaw = getColumnValue(row, ['units', 'einheiten', 'unidades', 'units sold', 'total units']);
// K (10): Units (30d) const salesRaw = getColumnValue(row, ['sales', 'umsatz', 'ventas', 'ad sales', 'total sales', 'sales (30d)']);
// L (11): Sales (30d)
const countryRaw = row[0];
const weekRaw = row[1];
const asin = row[2];
const costRaw = row[3];
const clicksRaw = row[4];
const impressionsRaw = row[5];
const cpcRaw = row[6];
const ctrRaw = row[7];
const acosRaw = row[8];
const conversionsRaw = row[9];
const unitsRaw = row[10];
const salesRaw = row[11];
// Skip if missing essential data // Skip if missing essential data
if (!asin || !countryRaw || weekRaw === undefined || weekRaw === '') continue; if (!asin || !countryRaw || weekRaw === undefined || weekRaw === '') continue;