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:
parent
42615e6ee0
commit
a4ed4e73c1
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
Loading…
Reference in New Issue