Add multi-column description mapping support

- Allow description field to map to multiple columns
- Update ColumnMapping type to accept string or string[] for description
- Join multiple description columns with newlines
- Add validation for multi-column descriptions
- Add test asset for multi-description CSV files
This commit is contained in:
Víctor Falcón 2025-12-01 10:30:11 +01:00
parent 42615e6ee0
commit a4ed4e73c1
5 changed files with 192 additions and 41 deletions

View File

@ -23,7 +23,7 @@ interface ImportStepMappingProps {
dateFormatDetected: boolean;
parsedData: ParsedRow[];
currencyCode: string;
onMappingChange: (field: keyof ColumnMapping, value: string) => void;
onMappingChange: (field: keyof ColumnMapping, value: string | string[]) => void;
onDateFormatChange: (format: DateFormat) => void;
onNext: () => void;
onBack: () => void;
@ -41,11 +41,61 @@ export function ImportStepMapping({
onNext,
onBack,
}: ImportStepMappingProps) {
const descriptionColumns = Array.isArray(columnMapping.description)
? columnMapping.description
: columnMapping.description
? [columnMapping.description]
: [];
const isValid =
columnMapping.transaction_date &&
columnMapping.description &&
columnMapping.amount;
const getDescriptionFromRow = (row: ParsedRow): string => {
if (!columnMapping.description) {
return '';
}
const columns = Array.isArray(columnMapping.description)
? columnMapping.description
: [columnMapping.description];
return columns
.map((col) => String(row[col] || '').trim())
.filter((val) => val.length > 0)
.join('\n');
};
const handleDescriptionChange = (index: number, value: string) => {
const newColumns = [...descriptionColumns];
if (value === '__none__') {
newColumns.splice(index, 1);
} else {
newColumns[index] = value;
}
if (newColumns.length === 0) {
onMappingChange('description', '');
} else if (newColumns.length === 1) {
onMappingChange('description', newColumns[0]);
} else {
onMappingChange('description', newColumns);
}
};
const addDescriptionColumn = () => {
if (descriptionColumns.length < 3) {
const newColumns = [...descriptionColumns, ''];
if (newColumns.length === 1) {
onMappingChange('description', '');
} else {
onMappingChange('description', newColumns);
}
}
};
const previewTransactions = parsedData.slice(0, 3).map((row) => {
const date = columnMapping.transaction_date
? parseDate(
@ -53,9 +103,7 @@ export function ImportStepMapping({
dateFormat,
)
: null;
const description = columnMapping.description
? String(row[columnMapping.description] || '')
: '';
const description = getDescriptionFromRow(row);
const amount = columnMapping.amount
? parseAmount(row[columnMapping.amount] as string | number)
: null;
@ -112,31 +160,89 @@ export function ImportStepMapping({
</div>
<div className="space-y-2">
<Label htmlFor="description-column">
<Label htmlFor="description-column-0">
Description <span className="text-destructive">*</span>
</Label>
<Select
value={columnMapping.description || ''}
onValueChange={(value) =>
onMappingChange('description', value)
}
>
<SelectTrigger id="description-column">
<SelectValue placeholder="Select description column" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((option, index) => (
<SelectItem
key={`desc-${option.value}-${index}`}
value={option.value}
{descriptionColumns.length === 0 ? (
<Select
value=""
onValueChange={(value) => handleDescriptionChange(0, value)}
>
<SelectTrigger id="description-column-0">
<SelectValue placeholder="Select description column" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((option, index) => (
<SelectItem
key={`desc-0-${option.value}-${index}`}
value={option.value}
>
<div className="flex flex-col">
<span>{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="space-y-3">
{descriptionColumns.map((column, columnIndex) => (
<div
key={columnIndex}
className={columnIndex > 0 ? 'pl-4' : ''}
>
<div className="flex flex-col">
<span>{option.label}</span>
</div>
</SelectItem>
<Select
value={column || ''}
onValueChange={(value) =>
handleDescriptionChange(columnIndex, value)
}
>
<SelectTrigger
id={`description-column-${columnIndex}`}
>
<SelectValue
placeholder={
columnIndex === 0
? 'Select description column'
: 'Select additional column'
}
/>
</SelectTrigger>
<SelectContent>
{columnIndex > 0 && (
<SelectItem value="__none__">
None (Remove)
</SelectItem>
)}
{columnOptions.map((option, index) => (
<SelectItem
key={`desc-${columnIndex}-${option.value}-${index}`}
value={option.value}
>
<div className="flex flex-col">
<span>{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</SelectContent>
</Select>
{descriptionColumns.length > 0 &&
descriptionColumns.length < 3 &&
descriptionColumns[descriptionColumns.length - 1] !== '' && (
<Button
type="button"
variant="outline"
size="sm"
onClick={addDescriptionColumn}
className="ml-4"
>
+ Add another column
</Button>
)}
</div>
)}
</div>
<div className="space-y-2">
@ -201,17 +307,17 @@ export function ImportStepMapping({
{previewTransactions.map((transaction, index) => (
<div
key={index}
className="flex items-center justify-between rounded-md bg-background p-3 text-sm"
className="flex items-start justify-between rounded-md bg-background p-3 text-sm gap-3"
>
<div className="flex flex-1 items-center gap-3">
<span className="text-muted-foreground">
<div className="flex flex-1 items-start gap-3">
<span className="text-muted-foreground whitespace-nowrap">
{transaction.date}
</span>
<span className="flex-1 truncate">
<span className="flex-1 whitespace-pre-line">
{transaction.description}
</span>
</div>
<span className="font-mono font-medium">
<span className="font-mono font-medium whitespace-nowrap">
{transaction.amount}
</span>
</div>

View File

@ -200,9 +200,14 @@ export function ImportTransactionsDrawer({
const values = Object.values(mapping).filter(
(v) => v !== null,
);
return values.every((value) =>
headers.includes(value as string),
);
return values.every((value) => {
if (Array.isArray(value)) {
return value.every((v) =>
headers.includes(v as string),
);
}
return headers.includes(value as string);
});
};
if (isValidMapping(savedConfig.columnMapping)) {
@ -230,7 +235,10 @@ export function ImportTransactionsDrawer({
}
};
const handleMappingChange = (field: keyof ColumnMapping, value: string) => {
const handleMappingChange = (
field: keyof ColumnMapping,
value: string | string[],
) => {
setState((prev) => ({
...prev,
columnMapping: {
@ -397,6 +405,18 @@ export function ImportTransactionsDrawer({
if (result.status === 'fulfilled') {
createdTransactions.push(result.value.transaction);
} else {
const errorMessage =
result.reason instanceof Error
? result.reason.message
: 'Unknown error';
console.error(`Transaction ${rowNumber} failed:`, {
transaction,
error: result.reason,
errorMessage,
stack: result.reason instanceof Error ? result.reason.stack : undefined,
});
errors.push({
rowNumber,
transaction: {
@ -404,10 +424,7 @@ export function ImportTransactionsDrawer({
description: transaction.description,
amount: transaction.amount.toString(),
},
error:
result.reason instanceof Error
? result.reason.message
: 'Unknown error',
error: errorMessage,
});
}
});

View File

@ -436,6 +436,21 @@ export function parseAmount(amountStr: string | number): number | null {
return amount;
}
function getDescriptionFromRow(row: ParsedRow, mapping: ColumnMapping): string {
if (!mapping.description) {
return '';
}
const columns = Array.isArray(mapping.description)
? mapping.description
: [mapping.description];
return columns
.map((col) => String(row[col] || '').trim())
.filter((val) => val.length > 0)
.join('\n');
}
export function validateTransaction(
row: ParsedRow,
mapping: ColumnMapping,
@ -455,8 +470,13 @@ export function validateTransaction(
}
}
if (!mapping.description || !row[mapping.description]) {
if (!mapping.description) {
errors.push('Missing description');
} else {
const description = getDescriptionFromRow(row, mapping);
if (!description) {
errors.push('Missing description');
}
}
if (
@ -497,7 +517,7 @@ export function convertRowsToTransactions(
dateFormat,
);
const amount = parseAmount(row[mapping.amount!] as string | number);
const description = String(row[mapping.description!] || '').trim();
const description = getDescriptionFromRow(row, mapping);
if (!date || amount === null || !description) {
continue;

View File

@ -13,7 +13,7 @@ export enum DateFormat {
export interface ColumnMapping {
transaction_date: string | null;
description: string | null;
description: string | string[] | null;
amount: string | null;
balance: string | null;
}

View File

@ -0,0 +1,8 @@
Date,Description1,Description2,Description3,Amount
2024-01-15,Grocery Store,Walmart,Food Shopping,-50.25
2024-01-16,Coffee Shop,Starbucks,Morning Coffee,-5.50
2024-01-17,Salary,Monthly Payment,Direct Deposit,2500.00
2024-01-18,Utilities,Electric Bill,January 2024,-125.00
2024-01-19,Restaurant,Italian Place,Dinner with Friends,-45.75
1 Date Description1 Description2 Description3 Amount
2 2024-01-15 Grocery Store Walmart Food Shopping -50.25
3 2024-01-16 Coffee Shop Starbucks Morning Coffee -5.50
4 2024-01-17 Salary Monthly Payment Direct Deposit 2500.00
5 2024-01-18 Utilities Electric Bill January 2024 -125.00
6 2024-01-19 Restaurant Italian Place Dinner with Friends -45.75