Simpl-Resultat/src/hooks/useImportWizard.ts
Le-King-Fu b190df4eae
Some checks failed
Release / build (ubuntu-22.04) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
feat: show transaction splits on Adjustments page + fix CSV auto-detect
Add a "Répartitions" section below manual adjustments listing all
split transactions. Clicking a split opens the existing modal to
view, edit, or delete it.

Fix CSV auto-detect failing on files with preamble lines (e.g.
Mastercard CSVs with metadata header). Three fixes:
- Delimiter detection uses mode of column counts instead of first-line
- Detect and skip preamble rows before header/data detection
- Exclude date-like columns from amount candidates and prefer columns
  with decimal values when picking the amount column

Bumps version to 0.3.4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 01:41:08 +00:00

1021 lines
33 KiB
TypeScript

import { useReducer, useCallback, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import Papa from "papaparse";
import type {
ImportWizardStep,
ScannedSource,
ScannedFile,
SourceConfig,
ParsedRow,
DuplicateCheckResult,
ImportReport,
ImportSource,
ImportConfigTemplate,
ColumnMapping,
} from "../shared/types";
import {
getImportFolder,
setImportFolder,
} from "../services/userPreferenceService";
import {
getAllSources,
getSourceByName,
createSource,
updateSource,
} from "../services/importSourceService";
import {
existsByHash,
createImportedFile,
updateFileStatus,
getFilesBySourceId,
} from "../services/importedFileService";
import {
insertBatch,
findDuplicates,
} from "../services/transactionService";
import { categorizeBatch } from "../services/categorizationService";
import {
getAllTemplates,
createTemplate,
updateTemplate,
deleteTemplate as deleteTemplateService,
} from "../services/importConfigTemplateService";
import { parseDate } from "../utils/dateParser";
import { parseFrenchAmount } from "../utils/amountParser";
import {
preprocessQuotedCSV,
autoDetectConfig as runAutoDetect,
} from "../utils/csvAutoDetect";
interface WizardState {
step: ImportWizardStep;
importFolder: string | null;
scannedSources: ScannedSource[];
selectedSource: ScannedSource | null;
selectedFiles: ScannedFile[];
sourceConfig: SourceConfig;
existingSource: ImportSource | null;
parsedPreview: ParsedRow[];
previewHeaders: string[];
duplicateResult: DuplicateCheckResult | null;
excludedDuplicateIndices: Set<number>;
importReport: ImportReport | null;
importProgress: { current: number; total: number; file: string };
isLoading: boolean;
error: string | null;
configuredSourceNames: Set<string>;
importedFilesBySource: Map<string, Set<string>>;
configTemplates: ImportConfigTemplate[];
selectedTemplateId: number | null;
}
type WizardAction =
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null }
| { type: "SET_STEP"; payload: ImportWizardStep }
| { type: "SET_IMPORT_FOLDER"; payload: string | null }
| { type: "SET_SCANNED_SOURCES"; payload: ScannedSource[] }
| { type: "SET_SELECTED_SOURCE"; payload: ScannedSource }
| { type: "SET_SELECTED_FILES"; payload: ScannedFile[] }
| { type: "SET_SOURCE_CONFIG"; payload: SourceConfig }
| { type: "SET_EXISTING_SOURCE"; payload: ImportSource | null }
| { type: "SET_PARSED_PREVIEW"; payload: { rows: ParsedRow[]; headers: string[] } }
| { type: "SET_DUPLICATE_RESULT"; payload: DuplicateCheckResult }
| { type: "TOGGLE_DUPLICATE_ROW"; payload: number }
| { type: "SET_SKIP_ALL_DUPLICATES"; payload: boolean }
| { type: "SET_IMPORT_REPORT"; payload: ImportReport }
| { type: "SET_IMPORT_PROGRESS"; payload: { current: number; total: number; file: string } }
| { type: "SET_CONFIGURED_SOURCES"; payload: { names: Set<string>; files: Map<string, Set<string>> } }
| { type: "SET_CONFIG_TEMPLATES"; payload: ImportConfigTemplate[] }
| { type: "SET_SELECTED_TEMPLATE_ID"; payload: number | null }
| { type: "RESET" };
const defaultConfig: SourceConfig = {
name: "",
delimiter: ";",
encoding: "utf-8",
dateFormat: "DD/MM/YYYY",
skipLines: 0,
columnMapping: { date: 0, description: 1, amount: 2 },
amountMode: "single",
signConvention: "negative_expense",
hasHeader: true,
};
const initialState: WizardState = {
step: "source-list",
importFolder: null,
scannedSources: [],
selectedSource: null,
selectedFiles: [],
sourceConfig: { ...defaultConfig },
existingSource: null,
parsedPreview: [],
previewHeaders: [],
duplicateResult: null,
excludedDuplicateIndices: new Set(),
importReport: null,
importProgress: { current: 0, total: 0, file: "" },
isLoading: false,
error: null,
configuredSourceNames: new Set(),
importedFilesBySource: new Map(),
configTemplates: [],
selectedTemplateId: null,
};
function reducer(state: WizardState, action: WizardAction): WizardState {
switch (action.type) {
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
case "SET_STEP":
return { ...state, step: action.payload };
case "SET_IMPORT_FOLDER":
return { ...state, importFolder: action.payload };
case "SET_SCANNED_SOURCES":
return { ...state, scannedSources: action.payload, isLoading: false };
case "SET_SELECTED_SOURCE":
return { ...state, selectedSource: action.payload };
case "SET_SELECTED_FILES":
return { ...state, selectedFiles: action.payload };
case "SET_SOURCE_CONFIG":
return { ...state, sourceConfig: action.payload };
case "SET_EXISTING_SOURCE":
return { ...state, existingSource: action.payload };
case "SET_PARSED_PREVIEW":
return {
...state,
parsedPreview: action.payload.rows,
previewHeaders: action.payload.headers,
isLoading: false,
};
case "SET_DUPLICATE_RESULT":
return {
...state,
duplicateResult: action.payload,
excludedDuplicateIndices: new Set(action.payload.duplicateRows.map((d) => d.rowIndex)),
isLoading: false,
};
case "TOGGLE_DUPLICATE_ROW": {
const next = new Set(state.excludedDuplicateIndices);
if (next.has(action.payload)) {
next.delete(action.payload);
} else {
next.add(action.payload);
}
return { ...state, excludedDuplicateIndices: next };
}
case "SET_SKIP_ALL_DUPLICATES":
return {
...state,
excludedDuplicateIndices: action.payload
? new Set(state.duplicateResult?.duplicateRows.map((d) => d.rowIndex) ?? [])
: new Set(),
};
case "SET_IMPORT_REPORT":
return { ...state, importReport: action.payload, isLoading: false };
case "SET_IMPORT_PROGRESS":
return { ...state, importProgress: action.payload };
case "SET_CONFIGURED_SOURCES":
return {
...state,
configuredSourceNames: action.payload.names,
importedFilesBySource: action.payload.files,
};
case "SET_CONFIG_TEMPLATES":
return { ...state, configTemplates: action.payload };
case "SET_SELECTED_TEMPLATE_ID":
return { ...state, selectedTemplateId: action.payload };
case "RESET":
return {
...initialState,
importFolder: state.importFolder,
scannedSources: state.scannedSources,
configuredSourceNames: state.configuredSourceNames,
importedFilesBySource: state.importedFilesBySource,
configTemplates: state.configTemplates,
};
default:
return state;
}
}
export function useImportWizard() {
const [state, dispatch] = useReducer(reducer, initialState);
// Load import folder on mount
useEffect(() => {
(async () => {
try {
const folder = await getImportFolder();
dispatch({ type: "SET_IMPORT_FOLDER", payload: folder });
if (folder) {
await scanFolderInternal(folder);
}
} catch {
// No folder configured yet
}
})();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const loadConfiguredSources = useCallback(async () => {
const sources = await getAllSources();
const names = new Set(sources.map((s) => s.name));
const files = new Map<string, Set<string>>();
for (const source of sources) {
const imported = await getFilesBySourceId(source.id);
files.set(
source.name,
new Set(imported.map((f) => f.filename))
);
}
dispatch({ type: "SET_CONFIGURED_SOURCES", payload: { names, files } });
const templates = await getAllTemplates();
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
}, []);
const scanFolderInternal = useCallback(
async (folder: string) => {
dispatch({ type: "SET_LOADING", payload: true });
try {
const sources = await invoke<ScannedSource[]>("scan_import_folder", {
folderPath: folder,
});
dispatch({ type: "SET_SCANNED_SOURCES", payload: sources });
await loadConfiguredSources();
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
},
[loadConfiguredSources]
);
const browseFolder = useCallback(async () => {
try {
const folder = await invoke<string | null>("pick_folder");
if (folder) {
await setImportFolder(folder);
dispatch({ type: "SET_IMPORT_FOLDER", payload: folder });
await scanFolderInternal(folder);
}
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [scanFolderInternal]);
const refreshFolder = useCallback(async () => {
if (state.importFolder) {
await scanFolderInternal(state.importFolder);
}
}, [state.importFolder, scanFolderInternal]);
const selectSource = useCallback(
async (source: ScannedSource) => {
// Sort files: new files first, then already-imported
const importedNames = state.importedFilesBySource.get(source.folder_name);
const sorted = [...source.files].sort((a, b) => {
const aImported = importedNames?.has(a.filename) ?? false;
const bImported = importedNames?.has(b.filename) ?? false;
if (aImported !== bImported) return aImported ? 1 : -1;
return a.filename.localeCompare(b.filename);
});
const sortedSource = { ...source, files: sorted };
// Pre-select only new files
const newFiles = sorted.filter((f) => !importedNames?.has(f.filename));
dispatch({ type: "SET_SELECTED_SOURCE", payload: sortedSource });
dispatch({ type: "SET_SELECTED_FILES", payload: newFiles });
dispatch({ type: "SET_SELECTED_TEMPLATE_ID", payload: null });
// Check if this source already has config in DB
const existing = await getSourceByName(source.folder_name);
dispatch({ type: "SET_EXISTING_SOURCE", payload: existing });
let activeDelimiter = defaultConfig.delimiter;
let activeEncoding = "utf-8";
let activeSkipLines = 0;
let activeHasHeader = true;
if (existing) {
// Restore config from DB
const mapping = JSON.parse(existing.column_mapping) as ColumnMapping;
const config: SourceConfig = {
name: existing.name,
delimiter: existing.delimiter,
encoding: existing.encoding,
dateFormat: existing.date_format,
skipLines: existing.skip_lines,
columnMapping: mapping,
amountMode:
mapping.debitAmount !== undefined ? "debit_credit" : "single",
signConvention: "negative_expense",
hasHeader: !!existing.has_header,
};
dispatch({ type: "SET_SOURCE_CONFIG", payload: config });
activeDelimiter = existing.delimiter;
activeEncoding = existing.encoding;
activeSkipLines = existing.skip_lines;
activeHasHeader = !!existing.has_header;
} else {
// Auto-detect encoding for first file
if (source.files.length > 0) {
try {
activeEncoding = await invoke<string>("detect_encoding", {
filePath: source.files[0].file_path,
});
} catch {
// fallback to utf-8
}
}
dispatch({
type: "SET_SOURCE_CONFIG",
payload: {
...defaultConfig,
name: source.folder_name,
encoding: activeEncoding,
},
});
}
// Load preview headers from first file
if (source.files.length > 0) {
await loadHeadersWithConfig(
source.files[0].file_path,
activeDelimiter,
activeEncoding,
activeSkipLines,
activeHasHeader
);
}
dispatch({ type: "SET_STEP", payload: "source-config" });
},
[state.importedFilesBySource] // eslint-disable-line react-hooks/exhaustive-deps
);
const loadHeadersWithConfig = useCallback(
async (filePath: string, delimiter: string, encoding: string, skipLines: number, hasHeader: boolean) => {
try {
const preview = await invoke<string>("get_file_preview", {
filePath,
encoding,
maxLines: skipLines + 5,
});
const preprocessed = preprocessQuotedCSV(preview);
const parsed = Papa.parse(preprocessed, { delimiter, skipEmptyLines: true });
const data = parsed.data as string[][];
const headerRow = hasHeader && data.length > skipLines ? skipLines : -1;
if (headerRow >= 0 && data[headerRow]) {
dispatch({
type: "SET_PARSED_PREVIEW",
payload: {
rows: [],
headers: data[headerRow].map((h) => h.trim()),
},
});
} else if (data.length > 0) {
// No header row — generate column indices as headers
const firstDataRow = data[skipLines] || data[0];
dispatch({
type: "SET_PARSED_PREVIEW",
payload: {
rows: [],
headers: firstDataRow.map((_, i) => `Col ${i}`),
},
});
}
} catch {
// ignore preview errors
}
},
[]
);
const updateConfig = useCallback(
(config: SourceConfig) => {
dispatch({ type: "SET_SOURCE_CONFIG", payload: config });
// Reload headers when delimiter, encoding, skipLines, or hasHeader changes
if (state.selectedFiles.length > 0) {
loadHeadersWithConfig(
state.selectedFiles[0].file_path,
config.delimiter,
config.encoding,
config.skipLines,
config.hasHeader
);
}
},
[state.selectedFiles, loadHeadersWithConfig]
);
const toggleFile = useCallback(
(file: ScannedFile) => {
const exists = state.selectedFiles.some(
(f) => f.file_path === file.file_path
);
if (exists) {
dispatch({
type: "SET_SELECTED_FILES",
payload: state.selectedFiles.filter(
(f) => f.file_path !== file.file_path
),
});
} else {
dispatch({
type: "SET_SELECTED_FILES",
payload: [...state.selectedFiles, file],
});
}
},
[state.selectedFiles]
);
const selectAllFiles = useCallback(() => {
if (state.selectedSource) {
const importedNames = state.importedFilesBySource.get(state.selectedSource.folder_name);
const newFiles = importedNames
? state.selectedSource.files.filter((f) => !importedNames.has(f.filename))
: state.selectedSource.files;
dispatch({
type: "SET_SELECTED_FILES",
payload: newFiles,
});
}
}, [state.selectedSource, state.importedFilesBySource]);
// Internal helper: parses selected files and returns rows + headers
const parseFilesInternal = useCallback(async (): Promise<{ rows: ParsedRow[]; headers: string[] }> => {
const config = state.sourceConfig;
const allRows: ParsedRow[] = [];
let headers: string[] = [];
for (const file of state.selectedFiles) {
const content = await invoke<string>("read_file_content", {
filePath: file.file_path,
encoding: config.encoding,
});
const preprocessed = preprocessQuotedCSV(content);
const parsed = Papa.parse(preprocessed, {
delimiter: config.delimiter,
skipEmptyLines: true,
});
const data = parsed.data as string[][];
const startIdx = config.skipLines + (config.hasHeader ? 1 : 0);
if (config.hasHeader && data.length > config.skipLines) {
headers = data[config.skipLines].map((h) => h.trim());
} else if (!config.hasHeader && headers.length === 0 && data.length > config.skipLines) {
const firstDataRow = data[config.skipLines];
headers = firstDataRow.map((_, i) => `Col ${i}`);
}
for (let i = startIdx; i < data.length; i++) {
const raw = data[i];
if (raw.length <= 1 && raw[0]?.trim() === "") continue;
try {
const date = parseDate(
raw[config.columnMapping.date]?.trim() || "",
config.dateFormat
);
const description =
raw[config.columnMapping.description]?.trim() || "";
let amount: number;
if (config.amountMode === "debit_credit") {
const debit = parseFrenchAmount(
raw[config.columnMapping.debitAmount ?? 0] || ""
);
const credit = parseFrenchAmount(
raw[config.columnMapping.creditAmount ?? 0] || ""
);
amount = isNaN(credit) ? -(isNaN(debit) ? 0 : debit) : credit;
} else {
amount = parseFrenchAmount(
raw[config.columnMapping.amount ?? 0] || ""
);
if (config.signConvention === "positive_expense" && !isNaN(amount)) {
amount = -amount;
}
}
if (!date) {
allRows.push({
rowIndex: allRows.length,
raw,
parsed: null,
error: "Invalid date",
sourceFilename: file.filename,
});
} else if (isNaN(amount)) {
allRows.push({
rowIndex: allRows.length,
raw,
parsed: null,
error: "Invalid amount",
sourceFilename: file.filename,
});
} else {
allRows.push({
rowIndex: allRows.length,
raw,
parsed: { date, description, amount },
sourceFilename: file.filename,
});
}
} catch {
allRows.push({
rowIndex: allRows.length,
raw,
parsed: null,
error: "Parse error",
sourceFilename: file.filename,
});
}
}
}
return { rows: allRows, headers };
}, [state.selectedFiles, state.sourceConfig]);
// Parse files and store preview (does NOT change wizard step)
const parsePreview = useCallback(async () => {
if (state.selectedFiles.length === 0) return;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const result = await parseFilesInternal();
dispatch({
type: "SET_PARSED_PREVIEW",
payload: result,
});
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [state.selectedFiles, parseFilesInternal]);
// Internal helper: runs duplicate checking against parsed rows
const checkDuplicatesInternal = useCallback(async (parsedRows: ParsedRow[]) => {
// Save/update source config in DB
const config = state.sourceConfig;
const mappingJson = JSON.stringify(config.columnMapping);
let sourceId: number;
if (state.existingSource) {
sourceId = state.existingSource.id;
await updateSource(sourceId, {
name: config.name,
delimiter: config.delimiter,
encoding: config.encoding,
date_format: config.dateFormat,
column_mapping: mappingJson,
skip_lines: config.skipLines,
has_header: config.hasHeader,
});
} else {
sourceId = await createSource({
name: config.name,
delimiter: config.delimiter,
encoding: config.encoding,
date_format: config.dateFormat,
column_mapping: mappingJson,
skip_lines: config.skipLines,
has_header: config.hasHeader,
});
}
// Check file-level duplicates (check ALL selected files, not just the first)
let fileAlreadyImported = false;
let existingFileId: number | undefined;
for (const file of state.selectedFiles) {
const hash = await invoke<string>("hash_file", {
filePath: file.file_path,
});
const existing = await existsByHash(hash);
if (existing) {
fileAlreadyImported = true;
existingFileId = existing.id;
break;
}
}
// Check row-level duplicates against DB
const validRows = parsedRows.filter((r) => r.parsed);
const duplicateMatches = await findDuplicates(
validRows.map((r) => ({
date: r.parsed!.date,
description: r.parsed!.description,
amount: r.parsed!.amount,
}))
);
const dbDuplicateIndices = new Set(duplicateMatches.map((d) => d.rowIndex));
const duplicateRows = duplicateMatches.map((d) => ({
rowIndex: d.rowIndex,
date: d.date,
description: d.description,
amount: d.amount,
existingTransactionId: d.existingTransactionId,
}));
// Cross-file duplicate detection: find rows that appear in multiple source files
const seenKeys = new Map<string, number>(); // key → first-seen validRows index
for (let i = 0; i < validRows.length; i++) {
if (dbDuplicateIndices.has(i)) continue; // already flagged as DB duplicate
const row = validRows[i];
const key = `${row.parsed!.date}|${row.parsed!.description}|${row.parsed!.amount}`;
const firstIdx = seenKeys.get(key);
if (firstIdx !== undefined) {
// Only flag as cross-file duplicate if rows come from different files
if (validRows[firstIdx].sourceFilename !== row.sourceFilename) {
duplicateRows.push({
rowIndex: i,
date: row.parsed!.date,
description: row.parsed!.description,
amount: row.parsed!.amount,
existingTransactionId: -1, // signals "within batch" in the UI
});
dbDuplicateIndices.add(i);
}
} else {
seenKeys.set(key, i);
}
}
const newRows = validRows.filter(
(_, i) => !dbDuplicateIndices.has(i)
);
dispatch({
type: "SET_DUPLICATE_RESULT",
payload: {
fileAlreadyImported,
existingFileId,
duplicateRows,
newRows,
},
});
dispatch({ type: "SET_STEP", payload: "duplicate-check" });
}, [state.sourceConfig, state.existingSource, state.selectedFiles]);
// Check duplicates using already-parsed preview data
const checkDuplicates = useCallback(async () => {
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
await checkDuplicatesInternal(state.parsedPreview);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [state.parsedPreview, checkDuplicatesInternal]);
// Parse files then check duplicates in one step (skips preview step)
const parseAndCheckDuplicates = useCallback(async () => {
if (state.selectedFiles.length === 0) return;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const result = await parseFilesInternal();
dispatch({
type: "SET_PARSED_PREVIEW",
payload: result,
});
await checkDuplicatesInternal(result.rows);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [state.selectedFiles, parseFilesInternal, checkDuplicatesInternal]);
const executeImport = useCallback(async () => {
if (!state.duplicateResult) return;
dispatch({ type: "SET_STEP", payload: "importing" });
dispatch({ type: "SET_ERROR", payload: null });
try {
const config = state.sourceConfig;
// Get or create source ID
const dbSource = await getSourceByName(config.name);
if (!dbSource) throw new Error("Source not found in database");
const sourceId = dbSource.id;
// Determine rows to import: new rows + non-excluded duplicates
const includedDuplicates = state.duplicateResult.duplicateRows
.filter((d) => !state.excludedDuplicateIndices.has(d.rowIndex));
const rowsToImport = [
...state.duplicateResult.newRows,
...state.parsedPreview.filter(
(r) =>
r.parsed &&
includedDuplicates.some((d) => d.rowIndex === r.rowIndex)
),
];
const validRows = rowsToImport.filter((r) => r.parsed);
const totalRows = validRows.length;
dispatch({
type: "SET_IMPORT_PROGRESS",
payload: { current: 0, total: totalRows, file: state.selectedFiles[0]?.filename || "" },
});
// Create one imported_files record per file
const fileIdMap = new Map<string, number>();
for (const file of state.selectedFiles) {
const hash = await invoke<string>("hash_file", {
filePath: file.file_path,
});
const rowCount = validRows.filter((r) => r.sourceFilename === file.filename).length;
const fId = await createImportedFile({
source_id: sourceId,
filename: file.filename,
file_hash: hash,
row_count: rowCount,
status: "completed",
});
fileIdMap.set(file.filename, fId);
}
// Auto-categorize
const descriptions = validRows.map((r) => r.parsed!.description);
const categorizations = await categorizeBatch(descriptions);
let categorizedCount = 0;
let uncategorizedCount = 0;
const errors: Array<{ rowIndex: number; message: string }> = [];
// Build transaction records
const transactions = validRows.map((row, i) => {
const cat = categorizations[i];
if (cat.category_id) {
categorizedCount++;
} else {
uncategorizedCount++;
}
return {
date: row.parsed!.date,
description: row.parsed!.description,
amount: row.parsed!.amount,
source_id: sourceId,
file_id: fileIdMap.get(row.sourceFilename || "") ?? 0,
original_description: row.raw.join(config.delimiter),
category_id: cat.category_id,
supplier_id: cat.supplier_id,
};
});
// Insert with progress
let importedCount = 0;
try {
importedCount = await insertBatch(transactions, (inserted) => {
const currentFile = validRows[inserted - 1]?.sourceFilename || "";
dispatch({
type: "SET_IMPORT_PROGRESS",
payload: { current: inserted, total: totalRows, file: currentFile },
});
});
dispatch({
type: "SET_IMPORT_PROGRESS",
payload: { current: importedCount, total: totalRows, file: "done" },
});
} catch (e) {
// Update status on all file records on error
for (const fId of fileIdMap.values()) {
await updateFileStatus(fId, "error", 0, String(e));
}
errors.push({
rowIndex: 0,
message: e instanceof Error ? e.message : String(e),
});
}
// Count errors from parsing
const parseErrors = state.parsedPreview.filter((r) => r.error);
for (const err of parseErrors) {
errors.push({ rowIndex: err.rowIndex, message: err.error || "Parse error" });
}
const report: ImportReport = {
totalRows: state.parsedPreview.length,
importedCount,
skippedDuplicates: state.excludedDuplicateIndices.size,
errorCount: errors.length,
categorizedCount,
uncategorizedCount,
errors,
};
dispatch({ type: "SET_IMPORT_REPORT", payload: report });
dispatch({ type: "SET_STEP", payload: "report" });
// Refresh configured sources
await loadConfiguredSources();
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
dispatch({ type: "SET_STEP", payload: "confirm" });
}
}, [
state.duplicateResult,
state.sourceConfig,
state.excludedDuplicateIndices,
state.parsedPreview,
state.selectedFiles,
loadConfiguredSources,
]);
const goToStep = useCallback((step: ImportWizardStep) => {
dispatch({ type: "SET_STEP", payload: step });
}, []);
const reset = useCallback(() => {
dispatch({ type: "RESET" });
}, []);
const autoDetectConfig = useCallback(async () => {
if (state.selectedFiles.length === 0) return;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const content = await invoke<string>("read_file_content", {
filePath: state.selectedFiles[0].file_path,
encoding: state.sourceConfig.encoding,
});
const result = runAutoDetect(content);
if (result) {
const newConfig = {
...state.sourceConfig,
delimiter: result.delimiter,
hasHeader: result.hasHeader,
skipLines: result.skipLines,
dateFormat: result.dateFormat,
columnMapping: result.columnMapping,
amountMode: result.amountMode,
signConvention: result.signConvention,
};
dispatch({ type: "SET_SOURCE_CONFIG", payload: newConfig });
dispatch({ type: "SET_LOADING", payload: false });
// Refresh column headers with new config
await loadHeadersWithConfig(
state.selectedFiles[0].file_path,
newConfig.delimiter,
newConfig.encoding,
newConfig.skipLines,
newConfig.hasHeader
);
} else {
dispatch({
type: "SET_ERROR",
payload: "Auto-detection failed. Please configure manually.",
});
}
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [state.selectedFiles, state.sourceConfig, loadHeadersWithConfig]);
const saveConfigAsTemplate = useCallback(async (name: string) => {
const config = state.sourceConfig;
await createTemplate({
name,
delimiter: config.delimiter,
encoding: config.encoding,
date_format: config.dateFormat,
skip_lines: config.skipLines,
has_header: config.hasHeader ? 1 : 0,
column_mapping: JSON.stringify(config.columnMapping),
amount_mode: config.amountMode,
sign_convention: config.signConvention,
});
const templates = await getAllTemplates();
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
}, [state.sourceConfig]);
const applyConfigTemplate = useCallback((templateId: number) => {
const template = state.configTemplates.find((t) => t.id === templateId);
if (!template) return;
const mapping = JSON.parse(template.column_mapping) as ColumnMapping;
const newConfig: SourceConfig = {
name: state.sourceConfig.name,
delimiter: template.delimiter,
encoding: template.encoding,
dateFormat: template.date_format,
skipLines: template.skip_lines,
columnMapping: mapping,
amountMode: template.amount_mode,
signConvention: template.sign_convention,
hasHeader: !!template.has_header,
};
dispatch({ type: "SET_SOURCE_CONFIG", payload: newConfig });
dispatch({ type: "SET_SELECTED_TEMPLATE_ID", payload: templateId });
// Reload headers with new config
if (state.selectedFiles.length > 0) {
loadHeadersWithConfig(
state.selectedFiles[0].file_path,
newConfig.delimiter,
newConfig.encoding,
newConfig.skipLines,
newConfig.hasHeader
);
}
}, [state.configTemplates, state.sourceConfig.name, state.selectedFiles, loadHeadersWithConfig]);
const updateConfigTemplate = useCallback(async () => {
if (!state.selectedTemplateId) return;
const template = state.configTemplates.find((t) => t.id === state.selectedTemplateId);
if (!template) return;
const config = state.sourceConfig;
await updateTemplate(state.selectedTemplateId, {
name: template.name,
delimiter: config.delimiter,
encoding: config.encoding,
date_format: config.dateFormat,
skip_lines: config.skipLines,
has_header: config.hasHeader ? 1 : 0,
column_mapping: JSON.stringify(config.columnMapping),
amount_mode: config.amountMode,
sign_convention: config.signConvention,
});
const templates = await getAllTemplates();
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
}, [state.selectedTemplateId, state.configTemplates, state.sourceConfig]);
const deleteConfigTemplate = useCallback(async (id: number) => {
await deleteTemplateService(id);
if (state.selectedTemplateId === id) {
dispatch({ type: "SET_SELECTED_TEMPLATE_ID", payload: null });
}
const templates = await getAllTemplates();
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
}, [state.selectedTemplateId]);
return {
state,
browseFolder,
refreshFolder,
selectSource,
updateConfig,
toggleFile,
selectAllFiles,
parsePreview,
checkDuplicates,
parseAndCheckDuplicates,
executeImport,
goToStep,
reset,
autoDetectConfig,
saveConfigAsTemplate,
applyConfigTemplate,
updateConfigTemplate,
deleteConfigTemplate,
toggleDuplicateRow: (index: number) =>
dispatch({ type: "TOGGLE_DUPLICATE_ROW", payload: index }),
setSkipAllDuplicates: (skipAll: boolean) =>
dispatch({ type: "SET_SKIP_ALL_DUPLICATES", payload: skipAll }),
};
}