feat(import-transactions-drawer): Add json-logic-js dependency and improve import logic

This commit is contained in:
Víctor Falcón 2025-11-10 23:27:35 +00:00
parent 4dcce018e5
commit 1df3bad3c3
3 changed files with 249 additions and 118 deletions

View File

@ -33,6 +33,7 @@
"date-fns": "^4.1.0",
"globals": "^15.14.0",
"input-otp": "^1.4.2",
"json-logic-js": "^2.0.5",
"laravel-vite-plugin": "^2.0",
"lucide-react": "^0.553.0",
"react": "^19.2.0",
@ -49,6 +50,7 @@
"devDependencies": {
"@eslint/js": "^9.19.0",
"@laravel/vite-plugin-wayfinder": "^0.1.3",
"@types/json-logic-js": "^2.0.8",
"@types/node": "^22.13.5",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.17.0",
@ -417,6 +419,8 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-logic-js": ["@types/json-logic-js@2.0.8", "", {}, "sha512-WgNsDPuTPKYXl0Jh0IfoCoJoAGGYZt5qzpmjuLSEg7r0cKp/kWtWp0HAsVepyPSPyXiHo6uXp/B/kW/2J1fa2Q=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="],
@ -777,6 +781,8 @@
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-logic-js": ["json-logic-js@2.0.5", "", {}, "sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],

View File

@ -1,33 +1,36 @@
import { useState, useEffect } from 'react';
import AlertError from '@/components/alert-error';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerDescription,
} from '@/components/ui/drawer';
import { ImportStepAccount } from './import-step-account';
import { ImportStepUpload } from './import-step-upload';
import { ImportStepMapping } from './import-step-mapping';
import { ImportStepPreview } from './import-step-preview';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { useSyncContext } from '@/contexts/sync-context';
import {
ImportStep,
DateFormat,
type ImportState,
type ColumnMapping,
} from '@/types/import';
import { type Account } from '@/types/account';
import {
parseFile,
autoDetectColumns,
convertRowsToTransactions,
parseFile,
} from '@/lib/file-parser';
import { transactionSyncService } from '@/services/transaction-sync';
import {
loadImportConfig,
saveImportConfig,
} from '@/lib/import-config-storage';
import { accountSyncService } from '@/services/account-sync';
import { useSyncContext } from '@/contexts/sync-context';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import AlertError from '@/components/alert-error';
import { saveImportConfig, loadImportConfig } from '@/lib/import-config-storage';
import { transactionSyncService } from '@/services/transaction-sync';
import { type Account } from '@/types/account';
import {
DateFormat,
ImportStep,
type ColumnMapping,
type ImportState,
} from '@/types/import';
import { useEffect, useState } from 'react';
import { ImportStepAccount } from './import-step-account';
import { ImportStepMapping } from './import-step-mapping';
import { ImportStepPreview } from './import-step-preview';
import { ImportStepUpload } from './import-step-upload';
interface ImportTransactionsDrawerProps {
open: boolean;
@ -42,7 +45,9 @@ export function ImportTransactionsDrawer({
const { isKeySet } = useEncryptionKey();
const [isImporting, setIsImporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
const [selectedAccount, setSelectedAccount] = useState<Account | null>(
null,
);
const [state, setState] = useState<ImportState>({
step: ImportStep.SelectAccount,
selectedAccountId: null,
@ -62,11 +67,13 @@ export function ImportTransactionsDrawer({
useEffect(() => {
if (state.selectedAccountId) {
accountSyncService.getById(state.selectedAccountId).then((account) => {
if (account) {
setSelectedAccount(account);
}
});
accountSyncService
.getById(state.selectedAccountId)
.then((account) => {
if (account) {
setSelectedAccount(account);
}
});
}
}, [state.selectedAccountId]);
@ -111,16 +118,25 @@ export function ImportTransactionsDrawer({
}
try {
const { headers, data, columns, headerRowIndex } = await parseFile(file);
const { headers, data, columns, headerRowIndex } =
await parseFile(file);
const autoMapping = autoDetectColumns(headers);
const columnOptions = headers.map((header, index) => {
const columnData = columns[index] || [];
const middleIndex = Math.floor(columnData.length / 2);
const examples = columnData
.slice(Math.max(headerRowIndex + 1, middleIndex), Math.max(headerRowIndex + 1, middleIndex) + 3)
.filter(cell => cell !== null && cell !== undefined && String(cell).trim() !== '')
.map(cell => String(cell))
.slice(
Math.max(headerRowIndex + 1, middleIndex),
Math.max(headerRowIndex + 1, middleIndex) + 3,
)
.filter(
(cell) =>
cell !== null &&
cell !== undefined &&
String(cell).trim() !== '',
)
.map((cell) => String(cell))
.slice(0, 3);
return {
@ -133,8 +149,13 @@ export function ImportTransactionsDrawer({
let detectedFormat = DateFormat.YearMonthDay;
let formatDetected = false;
if (autoMapping.transaction_date) {
const { autoDetectDateFormat } = await import('@/lib/file-parser');
const detected = autoDetectDateFormat(data, autoMapping.transaction_date);
const { autoDetectDateFormat } = await import(
'@/lib/file-parser'
);
const detected = autoDetectDateFormat(
data,
autoMapping.transaction_date,
);
if (detected) {
detectedFormat = detected;
formatDetected = true;
@ -148,9 +169,15 @@ export function ImportTransactionsDrawer({
const savedConfig = loadImportConfig(state.selectedAccountId);
if (savedConfig) {
const isValidMapping = (mapping: ColumnMapping): boolean => {
const values = Object.values(mapping).filter(v => v !== null);
return values.every(value => headers.includes(value as string));
const isValidMapping = (
mapping: ColumnMapping,
): boolean => {
const values = Object.values(mapping).filter(
(v) => v !== null,
);
return values.every((value) =>
headers.includes(value as string),
);
};
if (isValidMapping(savedConfig.columnMapping)) {
@ -173,17 +200,12 @@ export function ImportTransactionsDrawer({
}));
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Failed to parse file'
err instanceof Error ? err.message : 'Failed to parse file',
);
}
};
const handleMappingChange = (
field: keyof ColumnMapping,
value: string
) => {
const handleMappingChange = (field: keyof ColumnMapping, value: string) => {
setState((prev) => ({
...prev,
columnMapping: {
@ -202,11 +224,11 @@ export function ImportTransactionsDrawer({
const parsedTransactions = convertRowsToTransactions(
state.parsedData,
state.columnMapping,
state.dateFormat
state.dateFormat,
);
const account = await accountSyncService.getById(
state.selectedAccountId!
state.selectedAccountId!,
);
if (!account) {
@ -214,20 +236,16 @@ export function ImportTransactionsDrawer({
return;
}
const transactionsWithDuplicateCheck = await Promise.all(
parsedTransactions.map(async (transaction) => {
const isDuplicate = await transactionSyncService.isDuplicate(
account.id,
transaction.transaction_date,
transaction.amount,
transaction.description
);
const duplicateFlags = await transactionSyncService.checkDuplicates(
account.id,
parsedTransactions,
);
return {
...transaction,
isDuplicate,
};
})
const transactionsWithDuplicateCheck = parsedTransactions.map(
(transaction, index) => ({
...transaction,
isDuplicate: duplicateFlags[index],
}),
);
if (state.selectedAccountId) {
@ -246,7 +264,7 @@ export function ImportTransactionsDrawer({
setError(
err instanceof Error
? err.message
: 'Failed to process transactions'
: 'Failed to process transactions',
);
}
};
@ -266,18 +284,20 @@ export function ImportTransactionsDrawer({
}
const newTransactions = state.transactions.filter(
(t) => !t.isDuplicate
(t) => !t.isDuplicate,
);
const transactionsToImport = await Promise.all(
newTransactions.map(async (transaction) => {
const { encrypted, iv } =
await transactionSyncService.encryptDescription(
transaction.description
transaction.description,
);
return {
user_id: (selectedAccount as Account & { user_id?: number }).user_id || 0,
user_id:
(selectedAccount as Account & { user_id?: number })
.user_id || 0,
account_id: selectedAccount.id,
category_id: null,
description: encrypted,
@ -288,7 +308,7 @@ export function ImportTransactionsDrawer({
notes: null,
notes_iv: null,
};
})
}),
);
await transactionSyncService.createMany(transactionsToImport);
@ -300,7 +320,7 @@ export function ImportTransactionsDrawer({
setError(
err instanceof Error
? err.message
: 'Failed to import transactions'
: 'Failed to import transactions',
);
} finally {
setIsImporting(false);
@ -316,17 +336,20 @@ export function ImportTransactionsDrawer({
case ImportStep.SelectAccount:
return {
title: 'Select Account',
description: 'Choose the account where transactions will be imported',
description:
'Choose the account where transactions will be imported',
};
case ImportStep.UploadFile:
return {
title: 'Upload File',
description: 'Drop your CSV or Excel file here, or click to browse',
description:
'Drop your CSV or Excel file here, or click to browse',
};
case ImportStep.MapColumns:
return {
title: 'Map Columns',
description: 'Match your file columns to transaction fields',
description:
'Match your file columns to transaction fields',
};
case ImportStep.Preview:
return {
@ -398,7 +421,9 @@ export function ImportTransactionsDrawer({
<div className="mx-auto w-full max-w-3xl overflow-y-auto p-6">
<DrawerHeader className="px-0">
<DrawerTitle>{stepInfo.title}</DrawerTitle>
<DrawerDescription>{stepInfo.description}</DrawerDescription>
<DrawerDescription>
{stepInfo.description}
</DrawerDescription>
</DrawerHeader>
{error && (
<div className="mt-4">
@ -411,4 +436,3 @@ export function ImportTransactionsDrawer({
</Drawer>
);
}

View File

@ -1,7 +1,7 @@
import { SyncManager } from '@/lib/sync-manager';
import { indexedDBService } from '@/lib/indexeddb';
import { encrypt, importKey } from '@/lib/crypto';
import { indexedDBService } from '@/lib/indexeddb';
import { getStoredKey } from '@/lib/key-storage';
import { SyncManager } from '@/lib/sync-manager';
import { uuidv7 } from 'uuidv7';
export interface Transaction {
@ -45,18 +45,28 @@ class TransactionSyncService {
async getByAccountId(accountId: number): Promise<Transaction[]> {
try {
const allTransactions = await this.getAll();
return allTransactions.filter(t => t.account_id === accountId);
return allTransactions.filter((t) => t.account_id === accountId);
} catch (error) {
console.warn('Failed to get transactions from IndexedDB:', error);
return [];
}
}
async create(data: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>): Promise<Transaction> {
return await this.syncManager.createLocal<Transaction>(data as Omit<Transaction, 'id' | 'created_at' | 'updated_at'> & { id?: number; created_at?: string; updated_at?: string });
async create(
data: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>,
): Promise<Transaction> {
return await this.syncManager.createLocal<Transaction>(
data as Omit<Transaction, 'id' | 'created_at' | 'updated_at'> & {
id?: number;
created_at?: string;
updated_at?: string;
},
);
}
async createMany(transactions: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>[]): Promise<Transaction[]> {
async createMany(
transactions: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>[],
): Promise<Transaction[]> {
try {
const timestamp = new Date().toISOString();
const created: Transaction[] = [];
@ -82,7 +92,9 @@ class TransactionSyncService {
return created;
} catch (error) {
console.error('Failed to create transactions in IndexedDB:', error);
throw new Error('Failed to save transactions locally. Please refresh the page and try again.');
throw new Error(
'Failed to save transactions locally. Please refresh the page and try again.',
);
}
}
@ -99,7 +111,11 @@ class TransactionSyncService {
};
await indexedDBService.put('transactions', updated);
await indexedDBService.addPendingChange('transactions', 'update', updated);
await indexedDBService.addPendingChange(
'transactions',
'update',
updated,
);
}
async updateMany(ids: string[], data: Partial<Transaction>): Promise<void> {
@ -119,7 +135,11 @@ class TransactionSyncService {
};
await indexedDBService.put('transactions', updated);
await indexedDBService.addPendingChange('transactions', 'update', updated);
await indexedDBService.addPendingChange(
'transactions',
'update',
updated,
);
}
}
@ -130,7 +150,11 @@ class TransactionSyncService {
}
await indexedDBService.delete('transactions', id);
await indexedDBService.addPendingChange('transactions', 'delete', transaction);
await indexedDBService.addPendingChange(
'transactions',
'delete',
transaction,
);
}
async deleteMany(ids: string[]): Promise<void> {
@ -142,7 +166,116 @@ class TransactionSyncService {
}
await indexedDBService.delete('transactions', id);
await indexedDBService.addPendingChange('transactions', 'delete', transaction);
await indexedDBService.addPendingChange(
'transactions',
'delete',
transaction,
);
}
}
async checkDuplicates(
accountId: number,
transactions: Array<{
transaction_date: string;
amount: number;
description: string;
}>,
): Promise<boolean[]> {
try {
if (transactions.length === 0) {
return [];
}
const dates = transactions.map((t) => t.transaction_date);
const minDate = dates.reduce((a, b) => (a < b ? a : b));
const maxDate = dates.reduce((a, b) => (a > b ? a : b));
const allTransactions = await this.getByAccountId(accountId);
const transactionsInRange = allTransactions.filter(
(t) =>
t.transaction_date >= minDate &&
t.transaction_date <= maxDate,
);
console.log(
`Checking duplicates for ${transactions.length} transactions. Found ${transactionsInRange.length} existing transactions between ${minDate} and ${maxDate}`,
);
const keyString = getStoredKey();
if (!keyString) {
console.warn('No encryption key found for duplicate check');
return transactions.map(() => false);
}
const key = await importKey(keyString);
const { decrypt } = await import('@/lib/crypto');
const decryptedTransactions = await Promise.all(
transactionsInRange.map(async (t) => {
try {
const decryptedDescription = await decrypt(
t.description,
key,
t.description_iv,
);
return {
transaction_date: t.transaction_date,
amount: parseFloat(t.amount),
description: decryptedDescription
.toLowerCase()
.trim()
.replace(/\s+/g, ' '),
};
} catch (error) {
console.error(
'Failed to decrypt transaction:',
t.id,
error,
);
return null;
}
}),
);
const validDecryptedTransactions = decryptedTransactions.filter(
(t) => t !== null,
);
console.log(
`Successfully decrypted ${validDecryptedTransactions.length} transactions`,
);
const results = transactions.map((importingTx) => {
const normalizedDescription = importingTx.description
.toLowerCase()
.trim()
.replace(/\s+/g, ' ');
const isDuplicate = validDecryptedTransactions.some(
(existing) =>
existing.transaction_date ===
importingTx.transaction_date &&
Math.abs(existing.amount - importingTx.amount) <
0.001 &&
existing.description === normalizedDescription,
);
return isDuplicate;
});
const duplicateCount = results.filter((r) => r).length;
console.log(
`Found ${duplicateCount} duplicates out of ${transactions.length} transactions`,
);
return results;
} catch (error) {
console.warn(
'Duplicate check failed, assuming no duplicates:',
error,
);
return transactions.map(() => false);
}
}
@ -150,48 +283,17 @@ class TransactionSyncService {
accountId: number,
transactionDate: string,
amount: number,
description: string
description: string,
): Promise<boolean> {
try {
const existingTransactions = await this.getByAccountId(accountId);
const keyString = getStoredKey();
if (!keyString) {
return false;
}
const key = await importKey(keyString);
for (const existing of existingTransactions) {
if (
existing.transaction_date === transactionDate &&
parseFloat(existing.amount) === amount
) {
try {
const { decrypt } = await import('@/lib/crypto');
const decryptedExisting = await decrypt(
existing.description,
key,
existing.description_iv
);
if (decryptedExisting.toLowerCase() === description.toLowerCase()) {
return true;
}
} catch (error) {
console.error('Failed to decrypt description for duplicate check:', error);
}
}
}
return false;
} catch (error) {
console.warn('Duplicate check failed, assuming no duplicates:', error);
return false;
}
const results = await this.checkDuplicates(accountId, [
{ transaction_date: transactionDate, amount, description },
]);
return results[0] || false;
}
async encryptDescription(description: string): Promise<{ encrypted: string; iv: string }> {
async encryptDescription(
description: string,
): Promise<{ encrypted: string; iv: string }> {
const keyString = getStoredKey();
if (!keyString) {
throw new Error('Encryption key not set');
@ -211,4 +313,3 @@ class TransactionSyncService {
}
export const transactionSyncService = new TransactionSyncService();