feat: implement CSV import wizard with folder-based source detection

Full import pipeline: Rust backend (6 Tauri commands for folder scanning,
file reading, encoding detection, hashing, folder picker), TypeScript
services (DB, import sources, transactions, auto-categorization, user
preferences), utility parsers (French amounts, multi-format dates),
12 React components forming a 7-step wizard (source list, config,
column mapping, preview, duplicate detection, import, report), and
i18n support (FR/EN, ~60 keys each).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-08 03:38:46 +00:00
parent 801404ca21
commit 49e0bd2c94
30 changed files with 3054 additions and 8 deletions

79
src-tauri/Cargo.lock generated
View file

@ -762,6 +762,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.10.0",
"block2",
"libc",
"objc2",
]
@ -870,6 +872,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@ -3152,6 +3163,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "rsa"
version = "0.9.10"
@ -3528,12 +3563,16 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
name = "simpl-result"
version = "0.1.0"
dependencies = [
"encoding_rs",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-sql",
"walkdir",
]
[[package]]
@ -4144,6 +4183,46 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
dependencies = [
"anyhow",
"dunce",
"glob",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 0.9.11+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.3"

View file

@ -21,6 +21,10 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
encoding_rs = "0.8"
walkdir = "2"

View file

@ -8,6 +8,7 @@
"opener:default",
"sql:default",
"sql:allow-execute",
"sql:allow-select"
"sql:allow-select",
"dialog:default"
]
}

View file

@ -0,0 +1,209 @@
use encoding_rs::{UTF_8, WINDOWS_1252, ISO_8859_15};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::Path;
use tauri_plugin_dialog::DialogExt;
use walkdir::WalkDir;
#[derive(Debug, Serialize, Clone)]
pub struct ScannedFile {
pub filename: String,
pub file_path: String,
pub size_bytes: u64,
pub modified_at: String,
}
#[derive(Debug, Serialize, Clone)]
pub struct ScannedSource {
pub folder_name: String,
pub folder_path: String,
pub files: Vec<ScannedFile>,
}
#[tauri::command]
pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, String> {
let root = Path::new(&folder_path);
if !root.is_dir() {
return Err(format!("Folder does not exist: {}", folder_path));
}
let mut sources: Vec<ScannedSource> = Vec::new();
let entries = fs::read_dir(root).map_err(|e| format!("Cannot read folder: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Error reading entry: {}", e))?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let folder_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
// Skip hidden folders
if folder_name.starts_with('.') {
continue;
}
let mut files: Vec<ScannedFile> = Vec::new();
for file_entry in WalkDir::new(&path).max_depth(1).into_iter().flatten() {
let file_path = file_entry.path();
if !file_path.is_file() {
continue;
}
let ext = file_path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if ext != "csv" && ext != "txt" {
continue;
}
let metadata = fs::metadata(file_path)
.map_err(|e| format!("Cannot read metadata: {}", e))?;
let modified_at = metadata
.modified()
.map(|t| {
let duration = t
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
duration.as_secs().to_string()
})
.unwrap_or_default();
files.push(ScannedFile {
filename: file_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
file_path: file_path.to_string_lossy().to_string(),
size_bytes: metadata.len(),
modified_at,
});
}
files.sort_by(|a, b| a.filename.cmp(&b.filename));
sources.push(ScannedSource {
folder_name,
folder_path: path.to_string_lossy().to_string(),
files,
});
}
sources.sort_by(|a, b| a.folder_name.cmp(&b.folder_name));
Ok(sources)
}
#[tauri::command]
pub fn read_file_content(file_path: String, encoding: String) -> Result<String, String> {
let bytes = fs::read(&file_path).map_err(|e| format!("Cannot read file: {}", e))?;
let content = decode_bytes(&bytes, &encoding)?;
Ok(content)
}
#[tauri::command]
pub fn hash_file(file_path: String) -> Result<String, String> {
let bytes = fs::read(&file_path).map_err(|e| format!("Cannot read file: {}", e))?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
let result = hasher.finalize();
Ok(format!("{:x}", result))
}
#[tauri::command]
pub fn detect_encoding(file_path: String) -> Result<String, String> {
let bytes = fs::read(&file_path).map_err(|e| format!("Cannot read file: {}", e))?;
// Check BOM
if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
return Ok("utf-8".to_string());
}
if bytes.starts_with(&[0xFF, 0xFE]) {
return Ok("utf-16le".to_string());
}
if bytes.starts_with(&[0xFE, 0xFF]) {
return Ok("utf-16be".to_string());
}
// Try UTF-8 first
if std::str::from_utf8(&bytes).is_ok() {
return Ok("utf-8".to_string());
}
// Default to windows-1252 for French bank CSVs
Ok("windows-1252".to_string())
}
#[tauri::command]
pub fn get_file_preview(
file_path: String,
encoding: String,
max_lines: usize,
) -> Result<String, String> {
let bytes = fs::read(&file_path).map_err(|e| format!("Cannot read file: {}", e))?;
let content = decode_bytes(&bytes, &encoding)?;
let lines: Vec<&str> = content.lines().take(max_lines).collect();
Ok(lines.join("\n"))
}
#[tauri::command]
pub async fn pick_folder(app: tauri::AppHandle) -> Result<Option<String>, String> {
let folder = app
.dialog()
.file()
.blocking_pick_folder();
Ok(folder.map(|f| f.to_string()))
}
fn decode_bytes(bytes: &[u8], encoding: &str) -> Result<String, String> {
// Strip BOM if present
let bytes = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
&bytes[3..]
} else {
bytes
};
match encoding.to_lowercase().as_str() {
"utf-8" | "utf8" => {
String::from_utf8(bytes.to_vec()).map_err(|e| format!("UTF-8 decode error: {}", e))
}
"windows-1252" | "cp1252" => {
let (cow, _, had_errors) = WINDOWS_1252.decode(bytes);
if had_errors {
Err("Windows-1252 decode error".to_string())
} else {
Ok(cow.into_owned())
}
}
"iso-8859-1" | "iso-8859-15" | "latin1" | "latin9" => {
let (cow, _, had_errors) = ISO_8859_15.decode(bytes);
if had_errors {
Err("ISO-8859-15 decode error".to_string())
} else {
Ok(cow.into_owned())
}
}
_ => {
// Fallback to UTF-8
let (cow, _, _) = UTF_8.decode(bytes);
Ok(cow.into_owned())
}
}
}

View file

@ -0,0 +1,3 @@
pub mod fs_commands;
pub use fs_commands::*;

View file

@ -1,3 +1,4 @@
mod commands;
mod database;
use tauri_plugin_sql::{Migration, MigrationKind};
@ -13,11 +14,20 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations("sqlite:simpl_resultat.db", migrations)
.build(),
)
.invoke_handler(tauri::generate_handler![
commands::scan_import_folder,
commands::read_file_content,
commands::hash_file,
commands::detect_encoding,
commands::get_file_preview,
commands::pick_folder,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -0,0 +1,163 @@
import { useTranslation } from "react-i18next";
import type { ColumnMapping, AmountMode } from "../../shared/types";
interface ColumnMappingEditorProps {
headers: string[];
mapping: ColumnMapping;
amountMode: AmountMode;
onMappingChange: (mapping: ColumnMapping) => void;
onAmountModeChange: (mode: AmountMode) => void;
}
export default function ColumnMappingEditor({
headers,
mapping,
amountMode,
onMappingChange,
onAmountModeChange,
}: ColumnMappingEditorProps) {
const { t } = useTranslation();
const columnOptions = headers.map((h, i) => (
<option key={i} value={i}>
{i}: {h}
</option>
));
const selectClass =
"w-full px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]";
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-[var(--foreground)]">
{t("import.config.columnMapping")}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.dateColumn")}
</label>
<select
value={mapping.date}
onChange={(e) =>
onMappingChange({ ...mapping, date: parseInt(e.target.value) })
}
className={selectClass}
>
{columnOptions}
</select>
</div>
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.descriptionColumn")}
</label>
<select
value={mapping.description}
onChange={(e) =>
onMappingChange({
...mapping,
description: parseInt(e.target.value),
})
}
className={selectClass}
>
{columnOptions}
</select>
</div>
</div>
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.amountMode")}
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="amountMode"
value="single"
checked={amountMode === "single"}
onChange={() => onAmountModeChange("single")}
className="accent-[var(--primary)]"
/>
{t("import.config.singleAmount")}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="amountMode"
value="debit_credit"
checked={amountMode === "debit_credit"}
onChange={() => onAmountModeChange("debit_credit")}
className="accent-[var(--primary)]"
/>
{t("import.config.debitCredit")}
</label>
</div>
</div>
{amountMode === "single" ? (
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.amountColumn")}
</label>
<select
value={mapping.amount ?? 0}
onChange={(e) =>
onMappingChange({
...mapping,
amount: parseInt(e.target.value),
debitAmount: undefined,
creditAmount: undefined,
})
}
className={selectClass}
>
{columnOptions}
</select>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.debitColumn")}
</label>
<select
value={mapping.debitAmount ?? 0}
onChange={(e) =>
onMappingChange({
...mapping,
debitAmount: parseInt(e.target.value),
amount: undefined,
})
}
className={selectClass}
>
{columnOptions}
</select>
</div>
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.creditColumn")}
</label>
<select
value={mapping.creditAmount ?? 0}
onChange={(e) =>
onMappingChange({
...mapping,
creditAmount: parseInt(e.target.value),
amount: undefined,
})
}
className={selectClass}
>
{columnOptions}
</select>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,149 @@
import { useTranslation } from "react-i18next";
import { AlertTriangle, CheckCircle, FileWarning } from "lucide-react";
import type { DuplicateCheckResult } from "../../shared/types";
interface DuplicateCheckPanelProps {
result: DuplicateCheckResult;
onSkipDuplicates: () => void;
onIncludeAll: () => void;
skipDuplicates: boolean;
}
export default function DuplicateCheckPanel({
result,
onSkipDuplicates,
onIncludeAll,
skipDuplicates,
}: DuplicateCheckPanelProps) {
const { t } = useTranslation();
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold">
{t("import.duplicates.title")}
</h2>
{/* File-level duplicate */}
{result.fileAlreadyImported && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800">
<FileWarning size={20} className="text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
{t("import.duplicates.fileAlreadyImported")}
</p>
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">
{t("import.duplicates.fileAlreadyImportedDesc")}
</p>
</div>
</div>
)}
{/* Row-level duplicates */}
{result.duplicateRows.length > 0 ? (
<div>
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 mb-4">
<AlertTriangle
size={20}
className="text-amber-500 shrink-0 mt-0.5"
/>
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
{t("import.duplicates.rowsFound", {
count: result.duplicateRows.length,
})}
</p>
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">
{t("import.duplicates.rowsFoundDesc")}
</p>
</div>
</div>
{/* Duplicate action */}
<div className="flex gap-4 mb-4">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="duplicateAction"
checked={skipDuplicates}
onChange={onSkipDuplicates}
className="accent-[var(--primary)]"
/>
{t("import.duplicates.skip")}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="duplicateAction"
checked={!skipDuplicates}
onChange={onIncludeAll}
className="accent-[var(--primary)]"
/>
{t("import.duplicates.includeAll")}
</label>
</div>
{/* Duplicate table */}
<div className="overflow-x-auto rounded-xl border border-[var(--border)] max-h-64 overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0">
<tr className="bg-[var(--muted)]">
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--muted-foreground)]">
#
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--muted-foreground)]">
{t("import.preview.date")}
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--muted-foreground)]">
{t("import.preview.description")}
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-[var(--muted-foreground)]">
{t("import.preview.amount")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border)]">
{result.duplicateRows.map((row) => (
<tr
key={row.rowIndex}
className="bg-amber-50/50 dark:bg-amber-950/10"
>
<td className="px-3 py-2 text-[var(--muted-foreground)]">
{row.rowIndex + 1}
</td>
<td className="px-3 py-2">{row.date}</td>
<td className="px-3 py-2 max-w-xs truncate">
{row.description}
</td>
<td className="px-3 py-2 text-right font-mono">
{row.amount.toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
!result.fileAlreadyImported && (
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-50 dark:bg-emerald-950/20 border border-emerald-200 dark:border-emerald-800">
<CheckCircle size={20} className="text-emerald-500" />
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-200">
{t("import.duplicates.noneFound")}
</p>
</div>
)
)}
{/* Summary */}
<div className="p-4 rounded-xl bg-[var(--muted)]">
<p className="text-sm text-[var(--foreground)]">
{t("import.duplicates.summary", {
total: result.newRows.length + result.duplicateRows.length,
new: result.newRows.length,
duplicates: result.duplicateRows.length,
})}
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,102 @@
import { useTranslation } from "react-i18next";
import { AlertCircle } from "lucide-react";
import type { ParsedRow } from "../../shared/types";
interface FilePreviewTableProps {
rows: ParsedRow[];
}
export default function FilePreviewTable({
rows,
}: FilePreviewTableProps) {
const { t } = useTranslation();
if (rows.length === 0) {
return (
<div className="text-center py-8 text-[var(--muted-foreground)]">
{t("import.preview.noData")}
</div>
);
}
const errorCount = rows.filter((r) => r.error).length;
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">
{t("import.preview.title")}
</h2>
<div className="flex items-center gap-4 text-sm">
<span className="text-[var(--muted-foreground)]">
{t("import.preview.rowCount", { count: rows.length })}
</span>
{errorCount > 0 && (
<span className="flex items-center gap-1 text-red-500">
<AlertCircle size={14} />
{t("import.preview.errorCount", { count: errorCount })}
</span>
)}
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--muted)]">
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--muted-foreground)]">
#
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--muted-foreground)]">
{t("import.preview.date")}
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--muted-foreground)]">
{t("import.preview.description")}
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-[var(--muted-foreground)]">
{t("import.preview.amount")}
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--muted-foreground)]">
{t("import.preview.raw")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border)]">
{rows.map((row) => (
<tr
key={row.rowIndex}
className={
row.error
? "bg-red-50 dark:bg-red-950/20"
: "hover:bg-[var(--muted)]"
}
>
<td className="px-3 py-2 text-[var(--muted-foreground)]">
{row.rowIndex + 1}
</td>
<td className="px-3 py-2">
{row.parsed?.date || (
<span className="text-red-500 text-xs">
{row.error || "—"}
</span>
)}
</td>
<td className="px-3 py-2 max-w-xs truncate">
{row.parsed?.description || "—"}
</td>
<td className="px-3 py-2 text-right font-mono">
{row.parsed?.amount != null
? row.parsed.amount.toFixed(2)
: "—"}
</td>
<td className="px-3 py-2 text-xs text-[var(--muted-foreground)] max-w-xs truncate">
{row.raw.join(" | ")}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,98 @@
import { useTranslation } from "react-i18next";
import { FileText, Settings, CheckCircle } from "lucide-react";
import type { SourceConfig, ScannedFile, DuplicateCheckResult } from "../../shared/types";
interface ImportConfirmationProps {
sourceName: string;
config: SourceConfig;
selectedFiles: ScannedFile[];
duplicateResult: DuplicateCheckResult;
skipDuplicates: boolean;
}
export default function ImportConfirmation({
sourceName,
config,
selectedFiles,
duplicateResult,
skipDuplicates,
}: ImportConfirmationProps) {
const { t } = useTranslation();
const rowsToImport = skipDuplicates
? duplicateResult.newRows.length
: duplicateResult.newRows.length + duplicateResult.duplicateRows.length;
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold">
{t("import.confirm.title")}
</h2>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] divide-y divide-[var(--border)]">
{/* Source */}
<div className="p-4 flex items-center gap-3">
<Settings size={18} className="text-[var(--primary)]" />
<div>
<p className="text-sm font-medium">{t("import.confirm.source")}</p>
<p className="text-sm text-[var(--muted-foreground)]">
{sourceName}
</p>
</div>
</div>
{/* Files */}
<div className="p-4 flex items-center gap-3">
<FileText size={18} className="text-[var(--primary)]" />
<div>
<p className="text-sm font-medium">{t("import.confirm.files")}</p>
<p className="text-sm text-[var(--muted-foreground)]">
{selectedFiles.map((f) => f.filename).join(", ")}
</p>
</div>
</div>
{/* Config summary */}
<div className="p-4">
<p className="text-sm font-medium mb-2">
{t("import.confirm.settings")}
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-[var(--muted-foreground)]">
<div>
<span className="font-medium">{t("import.config.delimiter")}:</span>{" "}
{config.delimiter === ";" ? ";" : config.delimiter === "," ? "," : config.delimiter}
</div>
<div>
<span className="font-medium">{t("import.config.encoding")}:</span>{" "}
{config.encoding}
</div>
<div>
<span className="font-medium">{t("import.config.dateFormat")}:</span>{" "}
{config.dateFormat}
</div>
<div>
<span className="font-medium">{t("import.config.skipLines")}:</span>{" "}
{config.skipLines}
</div>
</div>
</div>
{/* Rows to import */}
<div className="p-4 flex items-center gap-3">
<CheckCircle size={18} className="text-emerald-500" />
<div>
<p className="text-sm font-medium">
{t("import.confirm.rowsToImport")}
</p>
<p className="text-sm text-[var(--muted-foreground)]">
{t("import.confirm.rowsSummary", {
count: rowsToImport,
skipped: skipDuplicates ? duplicateResult.duplicateRows.length : 0,
})}
</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,64 @@
import { useTranslation } from "react-i18next";
import { FolderOpen, RefreshCw } from "lucide-react";
interface ImportFolderConfigProps {
folderPath: string | null;
onBrowse: () => void;
onRefresh: () => void;
isLoading: boolean;
}
export default function ImportFolderConfig({
folderPath,
onBrowse,
onRefresh,
isLoading,
}: ImportFolderConfigProps) {
const { t } = useTranslation();
return (
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)] mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<FolderOpen size={20} className="text-[var(--primary)] shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--foreground)]">
{t("import.folder.label")}
</p>
{folderPath ? (
<p className="text-sm text-[var(--muted-foreground)] truncate">
{folderPath}
</p>
) : (
<p className="text-sm text-[var(--muted-foreground)] italic">
{t("import.folder.notConfigured")}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{folderPath && (
<button
onClick={onRefresh}
disabled={isLoading}
className="flex items-center gap-1 px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50"
>
<RefreshCw
size={14}
className={isLoading ? "animate-spin" : ""}
/>
{t("import.folder.refresh")}
</button>
)}
<button
onClick={onBrowse}
className="flex items-center gap-1 px-3 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
>
<FolderOpen size={14} />
{t("import.folder.browse")}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
interface ImportProgressProps {
currentFile: string;
progress: number;
total: number;
}
export default function ImportProgress({
currentFile,
progress,
total,
}: ImportProgressProps) {
const { t } = useTranslation();
const percentage = total > 0 ? Math.round((progress / total) * 100) : 0;
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold">
{t("import.progress.title")}
</h2>
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center">
<Loader2
size={40}
className="mx-auto mb-4 text-[var(--primary)] animate-spin"
/>
<p className="text-sm font-medium text-[var(--foreground)] mb-2">
{t("import.progress.importing")}
</p>
<p className="text-sm text-[var(--muted-foreground)] mb-4">
{currentFile}
</p>
{/* Progress bar */}
<div className="w-full max-w-md mx-auto">
<div className="flex items-center justify-between text-xs text-[var(--muted-foreground)] mb-1">
<span>
{progress} / {total} {t("import.progress.rows")}
</span>
<span>{percentage}%</span>
</div>
<div className="w-full h-2 rounded-full bg-[var(--muted)] overflow-hidden">
<div
className="h-full rounded-full bg-[var(--primary)] transition-all duration-300"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,127 @@
import { useTranslation } from "react-i18next";
import {
CheckCircle,
XCircle,
AlertTriangle,
Tag,
FileText,
} from "lucide-react";
import type { ImportReport } from "../../shared/types";
interface ImportReportPanelProps {
report: ImportReport;
onDone: () => void;
}
export default function ImportReportPanel({
report,
onDone,
}: ImportReportPanelProps) {
const { t } = useTranslation();
const stats = [
{
icon: FileText,
label: t("import.report.totalRows"),
value: report.totalRows,
color: "text-[var(--foreground)]",
},
{
icon: CheckCircle,
label: t("import.report.imported"),
value: report.importedCount,
color: "text-emerald-500",
},
{
icon: AlertTriangle,
label: t("import.report.skippedDuplicates"),
value: report.skippedDuplicates,
color: "text-amber-500",
},
{
icon: XCircle,
label: t("import.report.errors"),
value: report.errorCount,
color: "text-red-500",
},
{
icon: Tag,
label: t("import.report.categorized"),
value: report.categorizedCount,
color: "text-[var(--primary)]",
},
{
icon: Tag,
label: t("import.report.uncategorized"),
value: report.uncategorizedCount,
color: "text-[var(--muted-foreground)]",
},
];
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold">
{t("import.report.title")}
</h2>
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{stats.map((stat) => (
<div
key={stat.label}
className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)]"
>
<div className="flex items-center gap-2 mb-2">
<stat.icon size={16} className={stat.color} />
<span className="text-xs text-[var(--muted-foreground)]">
{stat.label}
</span>
</div>
<p className={`text-2xl font-bold ${stat.color}`}>{stat.value}</p>
</div>
))}
</div>
{/* Errors list */}
{report.errors.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-2 text-red-500">
{t("import.report.errorDetails")}
</h3>
<div className="max-h-48 overflow-y-auto rounded-xl border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--muted)]">
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--muted-foreground)]">
{t("import.report.row")}
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--muted-foreground)]">
{t("import.report.errorMessage")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border)]">
{report.errors.map((err, i) => (
<tr key={i}>
<td className="px-3 py-2">{err.rowIndex + 1}</td>
<td className="px-3 py-2 text-red-500">{err.message}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Done button */}
<div className="flex justify-center pt-4">
<button
onClick={onDone}
className="px-6 py-2 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
>
{t("import.report.done")}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
import { useTranslation } from "react-i18next";
import { FolderOpen, FileText, CheckCircle } from "lucide-react";
import type { ScannedSource } from "../../shared/types";
interface SourceCardProps {
source: ScannedSource;
isConfigured: boolean;
newFileCount: number;
onClick: () => void;
}
export default function SourceCard({
source,
isConfigured,
newFileCount,
onClick,
}: SourceCardProps) {
const { t } = useTranslation();
return (
<button
onClick={onClick}
className="w-full text-left bg-[var(--card)] rounded-xl p-4 border border-[var(--border)] hover:border-[var(--primary)] hover:shadow-sm transition-all"
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<FolderOpen size={18} className="text-[var(--primary)]" />
<h3 className="font-medium text-[var(--foreground)]">
{source.folder_name}
</h3>
</div>
<div className="flex items-center gap-1.5">
{newFileCount > 0 && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-[var(--accent)] text-white">
{newFileCount} {t("import.sources.new")}
</span>
)}
{isConfigured && (
<CheckCircle
size={16}
className="text-emerald-500"
/>
)}
</div>
</div>
<div className="flex items-center gap-1 text-sm text-[var(--muted-foreground)]">
<FileText size={14} />
<span>
{source.files.length}{" "}
{t("import.sources.fileCount", { count: source.files.length })}
</span>
</div>
</button>
);
}

View file

@ -0,0 +1,230 @@
import { useTranslation } from "react-i18next";
import type {
ScannedSource,
ScannedFile,
SourceConfig,
AmountMode,
ColumnMapping,
} from "../../shared/types";
import ColumnMappingEditor from "./ColumnMappingEditor";
interface SourceConfigPanelProps {
source: ScannedSource;
config: SourceConfig;
selectedFiles: ScannedFile[];
headers: string[];
onConfigChange: (config: SourceConfig) => void;
onFileToggle: (file: ScannedFile) => void;
onSelectAllFiles: () => void;
}
export default function SourceConfigPanel({
source,
config,
selectedFiles,
headers,
onConfigChange,
onFileToggle,
onSelectAllFiles,
}: SourceConfigPanelProps) {
const { t } = useTranslation();
const selectClass =
"w-full px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]";
const inputClass = selectClass;
const updateConfig = (partial: Partial<SourceConfig>) => {
onConfigChange({ ...config, ...partial });
};
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold">
{t("import.config.title")} {source.folder_name}
</h2>
{/* Source name */}
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.sourceName")}
</label>
<input
type="text"
value={config.name}
onChange={(e) => updateConfig({ name: e.target.value })}
className={inputClass}
/>
</div>
{/* Basic settings */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.delimiter")}
</label>
<select
value={config.delimiter}
onChange={(e) => updateConfig({ delimiter: e.target.value })}
className={selectClass}
>
<option value=";">{t("import.config.semicolon")} (;)</option>
<option value=",">{t("import.config.comma")} (,)</option>
<option value="\t">{t("import.config.tab")} ()</option>
<option value="|">Pipe (|)</option>
</select>
</div>
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.encoding")}
</label>
<select
value={config.encoding}
onChange={(e) => updateConfig({ encoding: e.target.value })}
className={selectClass}
>
<option value="utf-8">UTF-8</option>
<option value="windows-1252">Windows-1252</option>
<option value="iso-8859-1">ISO-8859-1</option>
<option value="iso-8859-15">ISO-8859-15</option>
</select>
</div>
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.dateFormat")}
</label>
<select
value={config.dateFormat}
onChange={(e) => updateConfig({ dateFormat: e.target.value })}
className={selectClass}
>
<option value="DD/MM/YYYY">DD/MM/YYYY</option>
<option value="MM/DD/YYYY">MM/DD/YYYY</option>
<option value="YYYY-MM-DD">YYYY-MM-DD</option>
<option value="DD-MM-YYYY">DD-MM-YYYY</option>
<option value="DD.MM.YYYY">DD.MM.YYYY</option>
</select>
</div>
</div>
{/* Skip lines & header */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.skipLines")}
</label>
<input
type="number"
min={0}
value={config.skipLines}
onChange={(e) =>
updateConfig({ skipLines: parseInt(e.target.value) || 0 })
}
className={inputClass}
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 text-sm cursor-pointer pb-2">
<input
type="checkbox"
checked={config.hasHeader}
onChange={(e) => updateConfig({ hasHeader: e.target.checked })}
className="accent-[var(--primary)]"
/>
{t("import.config.hasHeader")}
</label>
</div>
</div>
{/* Sign convention */}
<div>
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
{t("import.config.signConvention")}
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="signConvention"
value="negative_expense"
checked={config.signConvention === "negative_expense"}
onChange={() =>
updateConfig({ signConvention: "negative_expense" })
}
className="accent-[var(--primary)]"
/>
{t("import.config.negativeExpense")}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="signConvention"
value="positive_expense"
checked={config.signConvention === "positive_expense"}
onChange={() =>
updateConfig({ signConvention: "positive_expense" })
}
className="accent-[var(--primary)]"
/>
{t("import.config.positiveExpense")}
</label>
</div>
</div>
{/* Column mapping */}
{headers.length > 0 && (
<ColumnMappingEditor
headers={headers}
mapping={config.columnMapping}
amountMode={config.amountMode}
onMappingChange={(mapping: ColumnMapping) =>
onConfigChange({ ...config, columnMapping: mapping })
}
onAmountModeChange={(mode: AmountMode) =>
onConfigChange({ ...config, amountMode: mode })
}
/>
)}
{/* File selection */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-[var(--foreground)]">
{t("import.config.selectFiles")}
</h3>
<button
onClick={onSelectAllFiles}
className="text-xs text-[var(--primary)] hover:underline"
>
{t("import.config.selectAll")}
</button>
</div>
<div className="space-y-1 max-h-48 overflow-y-auto">
{source.files.map((file) => {
const isSelected = selectedFiles.some(
(f) => f.file_path === file.file_path
);
return (
<label
key={file.file_path}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--muted)] cursor-pointer text-sm"
>
<input
type="checkbox"
checked={isSelected}
onChange={() => onFileToggle(file)}
className="accent-[var(--primary)]"
/>
<span className="flex-1">{file.filename}</span>
<span className="text-xs text-[var(--muted-foreground)]">
{(file.size_bytes / 1024).toFixed(1)} KB
</span>
</label>
);
})}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,64 @@
import { useTranslation } from "react-i18next";
import { Inbox } from "lucide-react";
import type { ScannedSource } from "../../shared/types";
import SourceCard from "./SourceCard";
interface SourceListProps {
sources: ScannedSource[];
configuredSourceNames: Set<string>;
importedFileHashes: Map<string, Set<string>>;
onSelectSource: (source: ScannedSource) => void;
}
export default function SourceList({
sources,
configuredSourceNames,
importedFileHashes,
onSelectSource,
}: SourceListProps) {
const { t } = useTranslation();
if (sources.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl p-12 border-2 border-dashed border-[var(--border)] text-center">
<Inbox
size={40}
className="mx-auto mb-4 text-[var(--muted-foreground)]"
/>
<p className="text-[var(--muted-foreground)]">
{t("import.sources.empty")}
</p>
</div>
);
}
return (
<div>
<h2 className="text-lg font-semibold mb-4">
{t("import.sources.title")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sources.map((source) => {
const isConfigured = configuredSourceNames.has(source.folder_name);
// Count files not yet imported for this source
const sourceHashes = importedFileHashes.get(source.folder_name);
const newFileCount = sourceHashes
? source.files.filter(
(f) => !sourceHashes.has(f.filename)
).length
: source.files.length;
return (
<SourceCard
key={source.folder_path}
source={source}
isConfigured={isConfigured}
newFileCount={newFileCount}
onClick={() => onSelectSource(source)}
/>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,65 @@
import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
interface WizardNavigationProps {
onBack?: () => void;
onNext?: () => void;
onCancel?: () => void;
nextLabel?: string;
backLabel?: string;
nextDisabled?: boolean;
showBack?: boolean;
showNext?: boolean;
showCancel?: boolean;
}
export default function WizardNavigation({
onBack,
onNext,
onCancel,
nextLabel,
backLabel,
nextDisabled = false,
showBack = true,
showNext = true,
showCancel = true,
}: WizardNavigationProps) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between pt-6 border-t border-[var(--border)]">
<div>
{showCancel && onCancel && (
<button
onClick={onCancel}
className="flex items-center gap-1 px-4 py-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<X size={16} />
{t("common.cancel")}
</button>
)}
</div>
<div className="flex items-center gap-3">
{showBack && onBack && (
<button
onClick={onBack}
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<ChevronLeft size={16} />
{backLabel || t("import.wizard.back")}
</button>
)}
{showNext && onNext && (
<button
onClick={onNext}
disabled={nextDisabled}
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
{nextLabel || t("import.wizard.next")}
<ChevronRight size={16} />
</button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,708 @@
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,
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 { parseDate } from "../utils/dateParser";
import { parseFrenchAmount } from "../utils/amountParser";
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;
skipDuplicates: boolean;
importReport: ImportReport | null;
importProgress: { current: number; total: number; file: string };
isLoading: boolean;
error: string | null;
configuredSourceNames: Set<string>;
importedFilesBySource: Map<string, Set<string>>;
}
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: "SET_SKIP_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: "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,
skipDuplicates: true,
importReport: null,
importProgress: { current: 0, total: 0, file: "" },
isLoading: false,
error: null,
configuredSourceNames: new Set(),
importedFilesBySource: new Map(),
};
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, isLoading: false };
case "SET_SKIP_DUPLICATES":
return { ...state, skipDuplicates: action.payload };
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 "RESET":
return {
...initialState,
importFolder: state.importFolder,
scannedSources: state.scannedSources,
configuredSourceNames: state.configuredSourceNames,
importedFilesBySource: state.importedFilesBySource,
};
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 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) => {
dispatch({ type: "SET_SELECTED_SOURCE", payload: source });
dispatch({ type: "SET_SELECTED_FILES", payload: source.files });
// Check if this source already has config in DB
const existing = await getSourceByName(source.folder_name);
dispatch({ type: "SET_EXISTING_SOURCE", payload: existing });
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: true,
};
dispatch({ type: "SET_SOURCE_CONFIG", payload: config });
} else {
// Auto-detect encoding for first file
let encoding = "utf-8";
if (source.files.length > 0) {
try {
encoding = 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,
},
});
}
// Load preview headers from first file
if (source.files.length > 0) {
await loadHeaders(source.files[0].file_path, existing);
}
dispatch({ type: "SET_STEP", payload: "source-config" });
},
[] // eslint-disable-line react-hooks/exhaustive-deps
);
const loadHeaders = async (
filePath: string,
existing: ImportSource | null
) => {
try {
const encoding = existing?.encoding || "utf-8";
const preview = await invoke<string>("get_file_preview", {
filePath,
encoding,
maxLines: 5,
});
const delimiter = existing?.delimiter || ";";
const parsed = Papa.parse(preview, { delimiter });
if (parsed.data.length > 0) {
dispatch({
type: "SET_PARSED_PREVIEW",
payload: {
rows: [],
headers: (parsed.data[0] as string[]).map((h) => h.trim()),
},
});
}
} catch {
// ignore preview errors
}
};
const updateConfig = useCallback((config: SourceConfig) => {
dispatch({ type: "SET_SOURCE_CONFIG", payload: config });
}, []);
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) {
dispatch({
type: "SET_SELECTED_FILES",
payload: state.selectedSource.files,
});
}
}, [state.selectedSource]);
const parsePreview = useCallback(async () => {
if (state.selectedFiles.length === 0) return;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
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 parsed = Papa.parse(content, {
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());
}
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",
});
} else if (isNaN(amount)) {
allRows.push({
rowIndex: allRows.length,
raw,
parsed: null,
error: "Invalid amount",
});
} else {
allRows.push({
rowIndex: allRows.length,
raw,
parsed: { date, description, amount },
});
}
} catch {
allRows.push({
rowIndex: allRows.length,
raw,
parsed: null,
error: "Parse error",
});
}
}
}
dispatch({
type: "SET_PARSED_PREVIEW",
payload: { rows: allRows, headers },
});
dispatch({ type: "SET_STEP", payload: "file-preview" });
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [state.selectedFiles, state.sourceConfig]);
const checkDuplicates = useCallback(async () => {
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
// 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,
});
} else {
sourceId = await createSource({
name: config.name,
delimiter: config.delimiter,
encoding: config.encoding,
date_format: config.dateFormat,
column_mapping: mappingJson,
skip_lines: config.skipLines,
});
}
// Check file-level duplicates
let fileAlreadyImported = false;
let existingFileId: number | undefined;
if (state.selectedFiles.length > 0) {
const hash = await invoke<string>("hash_file", {
filePath: state.selectedFiles[0].file_path,
});
const existing = await existsByHash(sourceId, hash);
if (existing) {
fileAlreadyImported = true;
existingFileId = existing.id;
}
}
// Check row-level duplicates
const validRows = state.parsedPreview.filter((r) => r.parsed);
const duplicateMatches = await findDuplicates(
validRows.map((r) => ({
date: r.parsed!.date,
description: r.parsed!.description,
amount: r.parsed!.amount,
}))
);
const duplicateIndices = new Set(duplicateMatches.map((d) => d.rowIndex));
const newRows = validRows.filter(
(_, i) => !duplicateIndices.has(i)
);
const duplicateRows = duplicateMatches.map((d) => ({
rowIndex: d.rowIndex,
date: d.date,
description: d.description,
amount: d.amount,
existingTransactionId: d.existingTransactionId,
}));
dispatch({
type: "SET_DUPLICATE_RESULT",
payload: {
fileAlreadyImported,
existingFileId,
duplicateRows,
newRows,
},
});
dispatch({ type: "SET_STEP", payload: "duplicate-check" });
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [state.sourceConfig, state.existingSource, state.selectedFiles, state.parsedPreview]);
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
const rowsToImport = state.skipDuplicates
? state.duplicateResult.newRows
: [
...state.duplicateResult.newRows,
...state.parsedPreview.filter(
(r) =>
r.parsed &&
state.duplicateResult!.duplicateRows.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 imported file record
let fileHash = "";
if (state.selectedFiles.length > 0) {
fileHash = await invoke<string>("hash_file", {
filePath: state.selectedFiles[0].file_path,
});
}
const fileId = await createImportedFile({
source_id: sourceId,
filename: state.selectedFiles.map((f) => f.filename).join(", "),
file_hash: fileHash,
row_count: totalRows,
status: "completed",
});
// 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: fileId,
original_description: row.raw.join(config.delimiter),
category_id: cat.category_id,
supplier_id: cat.supplier_id,
};
});
// Insert in batches
let importedCount = 0;
try {
importedCount = await insertBatch(transactions);
dispatch({
type: "SET_IMPORT_PROGRESS",
payload: { current: importedCount, total: totalRows, file: "done" },
});
} catch (e) {
await updateFileStatus(fileId, "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.skipDuplicates
? state.duplicateResult.duplicateRows.length
: 0,
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.skipDuplicates,
state.parsedPreview,
state.selectedFiles,
loadConfiguredSources,
]);
const goToStep = useCallback((step: ImportWizardStep) => {
dispatch({ type: "SET_STEP", payload: step });
}, []);
const reset = useCallback(() => {
dispatch({ type: "RESET" });
}, []);
return {
state,
browseFolder,
refreshFolder,
selectSource,
updateConfig,
toggleFile,
selectAllFiles,
parsePreview,
checkDuplicates,
executeImport,
goToStep,
reset,
setSkipDuplicates: (v: boolean) =>
dispatch({ type: "SET_SKIP_DUPLICATES", payload: v }),
};
}

View file

@ -24,7 +24,103 @@
"source": "Source",
"file": "File",
"status": "Status",
"date": "Date"
"date": "Date",
"folder": {
"label": "Import folder",
"notConfigured": "No folder configured",
"browse": "Browse",
"refresh": "Refresh"
},
"sources": {
"title": "Import Sources",
"empty": "No sources found. Create subfolders in your import folder with CSV files.",
"new": "new",
"fileCount_one": "{{count}} file",
"fileCount_other": "{{count}} files",
"fileCount": "{{count}} file(s)"
},
"config": {
"title": "Source Configuration",
"sourceName": "Source name",
"delimiter": "Delimiter",
"semicolon": "Semicolon",
"comma": "Comma",
"tab": "Tab",
"encoding": "Encoding",
"dateFormat": "Date format",
"skipLines": "Lines to skip",
"hasHeader": "First row contains headers",
"signConvention": "Sign convention",
"negativeExpense": "Negative expenses",
"positiveExpense": "Positive expenses",
"columnMapping": "Column mapping",
"dateColumn": "Date column",
"descriptionColumn": "Description column",
"amountColumn": "Amount column",
"amountMode": "Amount mode",
"singleAmount": "Single amount",
"debitCredit": "Separate debit / credit",
"debitColumn": "Debit column",
"creditColumn": "Credit column",
"selectFiles": "Files to import",
"selectAll": "Select all"
},
"preview": {
"title": "Data Preview",
"noData": "No data to display",
"rowCount": "{{count}} row(s)",
"errorCount": "{{count}} error(s)",
"date": "Date",
"description": "Description",
"amount": "Amount",
"raw": "Raw data",
"moreRows": "... and {{count}} more row(s)"
},
"duplicates": {
"title": "Duplicate Detection",
"fileAlreadyImported": "This file has already been imported",
"fileAlreadyImportedDesc": "A file with the same content already exists in the database.",
"rowsFound": "{{count}} duplicate(s) found",
"rowsFoundDesc": "These rows match existing transactions.",
"noneFound": "No duplicates found",
"skip": "Skip duplicates",
"includeAll": "Import all",
"summary": "Total: {{total}} rows — {{new}} new — {{duplicates}} duplicate(s)"
},
"confirm": {
"title": "Import Confirmation",
"source": "Source",
"files": "Files",
"settings": "Settings",
"rowsToImport": "Rows to import",
"rowsSummary": "{{count}} row(s) to import, {{skipped}} duplicate(s) skipped"
},
"progress": {
"title": "Import in Progress",
"importing": "Importing...",
"rows": "rows"
},
"report": {
"title": "Import Report",
"totalRows": "Total rows",
"imported": "Imported",
"skippedDuplicates": "Skipped duplicates",
"errors": "Errors",
"categorized": "Categorized",
"uncategorized": "Uncategorized",
"errorDetails": "Error details",
"row": "Row",
"errorMessage": "Error message",
"done": "Done"
},
"wizard": {
"back": "Back",
"next": "Next",
"preview": "Preview",
"checkDuplicates": "Check duplicates",
"confirm": "Confirm",
"import": "Import"
}
},
"transactions": {
"title": "Transactions",

View file

@ -24,7 +24,103 @@
"source": "Source",
"file": "Fichier",
"status": "Statut",
"date": "Date"
"date": "Date",
"folder": {
"label": "Dossier d'import",
"notConfigured": "Aucun dossier configuré",
"browse": "Parcourir",
"refresh": "Actualiser"
},
"sources": {
"title": "Sources d'import",
"empty": "Aucune source trouvée. Créez des sous-dossiers dans votre dossier d'import avec des fichiers CSV.",
"new": "nouveau",
"fileCount_one": "{{count}} fichier",
"fileCount_other": "{{count}} fichiers",
"fileCount": "{{count}} fichier(s)"
},
"config": {
"title": "Configuration de la source",
"sourceName": "Nom de la source",
"delimiter": "Délimiteur",
"semicolon": "Point-virgule",
"comma": "Virgule",
"tab": "Tabulation",
"encoding": "Encodage",
"dateFormat": "Format de date",
"skipLines": "Lignes à ignorer",
"hasHeader": "La première ligne contient les en-têtes",
"signConvention": "Convention de signe",
"negativeExpense": "Dépenses négatives",
"positiveExpense": "Dépenses positives",
"columnMapping": "Mapping des colonnes",
"dateColumn": "Colonne date",
"descriptionColumn": "Colonne description",
"amountColumn": "Colonne montant",
"amountMode": "Mode montant",
"singleAmount": "Montant unique",
"debitCredit": "Débit / Crédit séparés",
"debitColumn": "Colonne débit",
"creditColumn": "Colonne crédit",
"selectFiles": "Fichiers à importer",
"selectAll": "Tout sélectionner"
},
"preview": {
"title": "Aperçu des données",
"noData": "Aucune donnée à afficher",
"rowCount": "{{count}} ligne(s)",
"errorCount": "{{count}} erreur(s)",
"date": "Date",
"description": "Description",
"amount": "Montant",
"raw": "Données brutes",
"moreRows": "... et {{count}} ligne(s) supplémentaire(s)"
},
"duplicates": {
"title": "Détection des doublons",
"fileAlreadyImported": "Ce fichier a déjà été importé",
"fileAlreadyImportedDesc": "Un fichier avec le même contenu existe déjà dans la base de données.",
"rowsFound": "{{count}} doublon(s) détecté(s)",
"rowsFoundDesc": "Ces lignes correspondent à des transactions déjà existantes.",
"noneFound": "Aucun doublon détecté",
"skip": "Ignorer les doublons",
"includeAll": "Tout importer",
"summary": "Total : {{total}} lignes — {{new}} nouvelles — {{duplicates}} doublon(s)"
},
"confirm": {
"title": "Confirmation de l'import",
"source": "Source",
"files": "Fichiers",
"settings": "Paramètres",
"rowsToImport": "Lignes à importer",
"rowsSummary": "{{count}} ligne(s) à importer, {{skipped}} doublon(s) ignoré(s)"
},
"progress": {
"title": "Import en cours",
"importing": "Import en cours...",
"rows": "lignes"
},
"report": {
"title": "Rapport d'import",
"totalRows": "Total lignes",
"imported": "Importées",
"skippedDuplicates": "Doublons ignorés",
"errors": "Erreurs",
"categorized": "Catégorisées",
"uncategorized": "Non catégorisées",
"errorDetails": "Détail des erreurs",
"row": "Ligne",
"errorMessage": "Message d'erreur",
"done": "Terminé"
},
"wizard": {
"back": "Retour",
"next": "Suivant",
"preview": "Aperçu",
"checkDuplicates": "Vérifier les doublons",
"confirm": "Confirmer",
"import": "Importer"
}
},
"transactions": {
"title": "Transactions",

View file

@ -1,16 +1,160 @@
import { useTranslation } from "react-i18next";
import { Upload } from "lucide-react";
import { useImportWizard } from "../hooks/useImportWizard";
import ImportFolderConfig from "../components/import/ImportFolderConfig";
import SourceList from "../components/import/SourceList";
import SourceConfigPanel from "../components/import/SourceConfigPanel";
import FilePreviewTable from "../components/import/FilePreviewTable";
import DuplicateCheckPanel from "../components/import/DuplicateCheckPanel";
import ImportConfirmation from "../components/import/ImportConfirmation";
import ImportProgress from "../components/import/ImportProgress";
import ImportReportPanel from "../components/import/ImportReportPanel";
import WizardNavigation from "../components/import/WizardNavigation";
import { AlertCircle } from "lucide-react";
export default function ImportPage() {
const { t } = useTranslation();
const {
state,
browseFolder,
refreshFolder,
selectSource,
updateConfig,
toggleFile,
selectAllFiles,
parsePreview,
checkDuplicates,
executeImport,
goToStep,
reset,
setSkipDuplicates,
} = useImportWizard();
return (
<div>
<h1 className="text-2xl font-bold mb-6">{t("import.title")}</h1>
<div className="bg-[var(--card)] rounded-xl p-12 border-2 border-dashed border-[var(--border)] text-center">
<Upload size={40} className="mx-auto mb-4 text-[var(--muted-foreground)]" />
<p className="text-[var(--muted-foreground)]">{t("import.dropzone")}</p>
</div>
{/* Error banner */}
{state.error && (
<div className="mb-4 p-3 rounded-xl bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 flex items-center gap-2">
<AlertCircle size={16} className="text-red-500 shrink-0" />
<p className="text-sm text-red-700 dark:text-red-300">
{state.error}
</p>
</div>
)}
{/* Folder config - always visible */}
<ImportFolderConfig
folderPath={state.importFolder}
onBrowse={browseFolder}
onRefresh={refreshFolder}
isLoading={state.isLoading}
/>
{/* Wizard steps */}
{state.step === "source-list" && (
<SourceList
sources={state.scannedSources}
configuredSourceNames={state.configuredSourceNames}
importedFileHashes={state.importedFilesBySource}
onSelectSource={selectSource}
/>
)}
{state.step === "source-config" && state.selectedSource && (
<div className="space-y-6">
<SourceConfigPanel
source={state.selectedSource}
config={state.sourceConfig}
selectedFiles={state.selectedFiles}
headers={state.previewHeaders}
onConfigChange={updateConfig}
onFileToggle={toggleFile}
onSelectAllFiles={selectAllFiles}
/>
<WizardNavigation
onBack={() => goToStep("source-list")}
onNext={parsePreview}
onCancel={reset}
nextLabel={t("import.wizard.preview")}
nextDisabled={
state.selectedFiles.length === 0 || !state.sourceConfig.name
}
/>
</div>
)}
{state.step === "file-preview" && (
<div className="space-y-6">
<FilePreviewTable
rows={state.parsedPreview.slice(0, 20)}
/>
{state.parsedPreview.length > 20 && (
<p className="text-sm text-[var(--muted-foreground)] text-center">
{t("import.preview.moreRows", {
count: state.parsedPreview.length - 20,
})}
</p>
)}
<WizardNavigation
onBack={() => goToStep("source-config")}
onNext={checkDuplicates}
onCancel={reset}
nextLabel={t("import.wizard.checkDuplicates")}
nextDisabled={
state.parsedPreview.filter((r) => r.parsed).length === 0
}
/>
</div>
)}
{state.step === "duplicate-check" && state.duplicateResult && (
<div className="space-y-6">
<DuplicateCheckPanel
result={state.duplicateResult}
skipDuplicates={state.skipDuplicates}
onSkipDuplicates={() => setSkipDuplicates(true)}
onIncludeAll={() => setSkipDuplicates(false)}
/>
<WizardNavigation
onBack={() => goToStep("file-preview")}
onNext={() => goToStep("confirm")}
onCancel={reset}
nextLabel={t("import.wizard.confirm")}
/>
</div>
)}
{state.step === "confirm" && state.duplicateResult && (
<div className="space-y-6">
<ImportConfirmation
sourceName={state.sourceConfig.name}
config={state.sourceConfig}
selectedFiles={state.selectedFiles}
duplicateResult={state.duplicateResult}
skipDuplicates={state.skipDuplicates}
/>
<WizardNavigation
onBack={() => goToStep("duplicate-check")}
onNext={executeImport}
onCancel={reset}
nextLabel={t("import.wizard.import")}
showCancel={false}
/>
</div>
)}
{state.step === "importing" && (
<ImportProgress
currentFile={state.importProgress.file}
progress={state.importProgress.current}
total={state.importProgress.total}
/>
)}
{state.step === "report" && state.importReport && (
<ImportReportPanel report={state.importReport} onDone={reset} />
)}
</div>
);
}

View file

@ -0,0 +1,76 @@
import { getDb } from "./db";
import type { Keyword } from "../shared/types";
/**
* Normalize a description for keyword matching:
* - lowercase
* - strip accents via NFD decomposition
* - collapse whitespace
*/
function normalizeDescription(desc: string): string {
return desc
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/\s+/g, " ")
.trim();
}
interface CategorizationResult {
category_id: number | null;
supplier_id: number | null;
}
/**
* Auto-categorize a single transaction description.
* Returns matching category_id and supplier_id, or nulls if no match.
*/
export async function categorizeDescription(
description: string
): Promise<CategorizationResult> {
const db = await getDb();
const keywords = await db.select<Keyword[]>(
"SELECT * FROM keywords WHERE is_active = 1 ORDER BY priority DESC"
);
const normalized = normalizeDescription(description);
for (const kw of keywords) {
const normalizedKeyword = normalizeDescription(kw.keyword);
if (normalized.includes(normalizedKeyword)) {
return {
category_id: kw.category_id,
supplier_id: kw.supplier_id ?? null,
};
}
}
return { category_id: null, supplier_id: null };
}
/**
* Auto-categorize a batch of transactions (by their descriptions).
* Returns an array of results in the same order.
*/
export async function categorizeBatch(
descriptions: string[]
): Promise<CategorizationResult[]> {
const db = await getDb();
const keywords = await db.select<Keyword[]>(
"SELECT * FROM keywords WHERE is_active = 1 ORDER BY priority DESC"
);
return descriptions.map((desc) => {
const normalized = normalizeDescription(desc);
for (const kw of keywords) {
const normalizedKeyword = normalizeDescription(kw.keyword);
if (normalized.includes(normalizedKeyword)) {
return {
category_id: kw.category_id,
supplier_id: kw.supplier_id ?? null,
};
}
}
return { category_id: null, supplier_id: null };
});
}

10
src/services/db.ts Normal file
View file

@ -0,0 +1,10 @@
import Database from "@tauri-apps/plugin-sql";
let dbInstance: Database | null = null;
export async function getDb(): Promise<Database> {
if (!dbInstance) {
dbInstance = await Database.load("sqlite:simpl_resultat.db");
}
return dbInstance;
}

View file

@ -0,0 +1,98 @@
import { getDb } from "./db";
import type { ImportSource } from "../shared/types";
export async function getAllSources(): Promise<ImportSource[]> {
const db = await getDb();
return db.select<ImportSource[]>("SELECT * FROM import_sources ORDER BY name");
}
export async function getSourceByName(
name: string
): Promise<ImportSource | null> {
const db = await getDb();
const rows = await db.select<ImportSource[]>(
"SELECT * FROM import_sources WHERE name = $1",
[name]
);
return rows.length > 0 ? rows[0] : null;
}
export async function getSourceById(
id: number
): Promise<ImportSource | null> {
const db = await getDb();
const rows = await db.select<ImportSource[]>(
"SELECT * FROM import_sources WHERE id = $1",
[id]
);
return rows.length > 0 ? rows[0] : null;
}
export async function createSource(
source: Omit<ImportSource, "id" | "created_at" | "updated_at">
): Promise<number> {
const db = await getDb();
const result = await db.execute(
`INSERT INTO import_sources (name, description, date_format, delimiter, encoding, column_mapping, skip_lines)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
source.name,
source.description || null,
source.date_format,
source.delimiter,
source.encoding,
source.column_mapping,
source.skip_lines,
]
);
return result.lastInsertId as number;
}
export async function updateSource(
id: number,
source: Partial<Omit<ImportSource, "id" | "created_at" | "updated_at">>
): Promise<void> {
const db = await getDb();
const fields: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (source.name !== undefined) {
fields.push(`name = $${paramIndex++}`);
values.push(source.name);
}
if (source.description !== undefined) {
fields.push(`description = $${paramIndex++}`);
values.push(source.description);
}
if (source.date_format !== undefined) {
fields.push(`date_format = $${paramIndex++}`);
values.push(source.date_format);
}
if (source.delimiter !== undefined) {
fields.push(`delimiter = $${paramIndex++}`);
values.push(source.delimiter);
}
if (source.encoding !== undefined) {
fields.push(`encoding = $${paramIndex++}`);
values.push(source.encoding);
}
if (source.column_mapping !== undefined) {
fields.push(`column_mapping = $${paramIndex++}`);
values.push(source.column_mapping);
}
if (source.skip_lines !== undefined) {
fields.push(`skip_lines = $${paramIndex++}`);
values.push(source.skip_lines);
}
if (fields.length === 0) return;
fields.push(`updated_at = CURRENT_TIMESTAMP`);
values.push(id);
await db.execute(
`UPDATE import_sources SET ${fields.join(", ")} WHERE id = $${paramIndex}`,
values
);
}

View file

@ -0,0 +1,61 @@
import { getDb } from "./db";
import type { ImportedFile } from "../shared/types";
export async function getFilesBySourceId(
sourceId: number
): Promise<ImportedFile[]> {
const db = await getDb();
return db.select<ImportedFile[]>(
"SELECT * FROM imported_files WHERE source_id = $1 ORDER BY import_date DESC",
[sourceId]
);
}
export async function existsByHash(
sourceId: number,
fileHash: string
): Promise<ImportedFile | null> {
const db = await getDb();
const rows = await db.select<ImportedFile[]>(
"SELECT * FROM imported_files WHERE source_id = $1 AND file_hash = $2",
[sourceId, fileHash]
);
return rows.length > 0 ? rows[0] : null;
}
export async function createImportedFile(file: {
source_id: number;
filename: string;
file_hash: string;
row_count: number;
status: string;
notes?: string;
}): Promise<number> {
const db = await getDb();
const result = await db.execute(
`INSERT INTO imported_files (source_id, filename, file_hash, row_count, status, notes)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
file.source_id,
file.filename,
file.file_hash,
file.row_count,
file.status,
file.notes || null,
]
);
return result.lastInsertId as number;
}
export async function updateFileStatus(
id: number,
status: string,
rowCount?: number,
notes?: string
): Promise<void> {
const db = await getDb();
await db.execute(
`UPDATE imported_files SET status = $1, row_count = COALESCE($2, row_count), notes = COALESCE($3, notes) WHERE id = $4`,
[status, rowCount ?? null, notes ?? null, id]
);
}

View file

@ -0,0 +1,91 @@
import { getDb } from "./db";
import type { Transaction } from "../shared/types";
export async function insertBatch(
transactions: Array<{
date: string;
description: string;
amount: number;
source_id: number;
file_id: number;
original_description: string;
category_id?: number | null;
supplier_id?: number | null;
}>
): Promise<number> {
const db = await getDb();
let insertedCount = 0;
// Process in batches of 500
const batchSize = 500;
for (let i = 0; i < transactions.length; i += batchSize) {
const batch = transactions.slice(i, i + batchSize);
await db.execute("BEGIN TRANSACTION", []);
try {
for (const tx of batch) {
await db.execute(
`INSERT INTO transactions (date, description, amount, source_id, file_id, original_description, category_id, supplier_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
tx.date,
tx.description,
tx.amount,
tx.source_id,
tx.file_id,
tx.original_description,
tx.category_id ?? null,
tx.supplier_id ?? null,
]
);
insertedCount++;
}
await db.execute("COMMIT", []);
} catch (e) {
await db.execute("ROLLBACK", []);
throw e;
}
}
return insertedCount;
}
export async function findDuplicates(
rows: Array<{ date: string; description: string; amount: number }>
): Promise<
Array<{
rowIndex: number;
existingTransactionId: number;
date: string;
description: string;
amount: number;
}>
> {
const db = await getDb();
const duplicates: Array<{
rowIndex: number;
existingTransactionId: number;
date: string;
description: string;
amount: number;
}> = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const existing = await db.select<Transaction[]>(
`SELECT id FROM transactions WHERE date = $1 AND description = $2 AND amount = $3 LIMIT 1`,
[row.date, row.description, row.amount]
);
if (existing.length > 0) {
duplicates.push({
rowIndex: i,
existingTransactionId: existing[0].id,
date: row.date,
description: row.description,
amount: row.amount,
});
}
}
return duplicates;
}

View file

@ -0,0 +1,32 @@
import { getDb } from "./db";
import type { UserPreference } from "../shared/types";
export async function getPreference(key: string): Promise<string | null> {
const db = await getDb();
const rows = await db.select<UserPreference[]>(
"SELECT * FROM user_preferences WHERE key = $1",
[key]
);
return rows.length > 0 ? rows[0].value : null;
}
export async function setPreference(
key: string,
value: string
): Promise<void> {
const db = await getDb();
await db.execute(
`INSERT INTO user_preferences (key, value, updated_at)
VALUES ($1, $2, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP`,
[key, value]
);
}
export async function getImportFolder(): Promise<string | null> {
return getPreference("import_folder");
}
export async function setImportFolder(path: string): Promise<void> {
return setPreference("import_folder", path);
}

View file

@ -127,3 +127,86 @@ export interface NavItem {
icon: string;
labelKey: string;
}
// --- Import Wizard Types ---
export interface ScannedFile {
filename: string;
file_path: string;
size_bytes: number;
modified_at: string;
}
export interface ScannedSource {
folder_name: string;
folder_path: string;
files: ScannedFile[];
}
export interface ColumnMapping {
date: number;
description: number;
amount?: number;
debitAmount?: number;
creditAmount?: number;
}
export type AmountMode = "single" | "debit_credit";
export type SignConvention = "negative_expense" | "positive_expense";
export interface SourceConfig {
name: string;
delimiter: string;
encoding: string;
dateFormat: string;
skipLines: number;
columnMapping: ColumnMapping;
amountMode: AmountMode;
signConvention: SignConvention;
hasHeader: boolean;
}
export interface ParsedRow {
rowIndex: number;
raw: string[];
parsed: {
date: string;
description: string;
amount: number;
} | null;
error?: string;
}
export interface DuplicateRow {
rowIndex: number;
date: string;
description: string;
amount: number;
existingTransactionId: number;
}
export interface DuplicateCheckResult {
fileAlreadyImported: boolean;
existingFileId?: number;
duplicateRows: DuplicateRow[];
newRows: ParsedRow[];
}
export interface ImportReport {
totalRows: number;
importedCount: number;
skippedDuplicates: number;
errorCount: number;
categorizedCount: number;
uncategorizedCount: number;
errors: Array<{ rowIndex: number; message: string }>;
}
export type ImportWizardStep =
| "source-list"
| "source-config"
| "file-preview"
| "duplicate-check"
| "confirm"
| "importing"
| "report";

26
src/utils/amountParser.ts Normal file
View file

@ -0,0 +1,26 @@
/**
* Parse a French-formatted amount string to a number.
* Handles formats like: 1.234,56 / 1234,56 / -1 234.56 / 1 234,56
*/
export function parseFrenchAmount(raw: string): number {
if (!raw || typeof raw !== "string") return NaN;
let cleaned = raw.trim();
// Remove currency symbols and whitespace
cleaned = cleaned.replace(/[€$£\s\u00A0]/g, "");
// Detect if comma is decimal separator (French style)
// Pattern: digits followed by comma followed by exactly 1-2 digits at end
const frenchPattern = /,\d{1,2}$/;
if (frenchPattern.test(cleaned)) {
// French format: remove dots (thousand sep), replace comma with dot (decimal)
cleaned = cleaned.replace(/\./g, "").replace(",", ".");
} else {
// English format or no decimal: remove commas (thousand sep)
cleaned = cleaned.replace(/,/g, "");
}
const result = parseFloat(cleaned);
return isNaN(result) ? NaN : result;
}

47
src/utils/dateParser.ts Normal file
View file

@ -0,0 +1,47 @@
/**
* Parse a date string with a given format and return ISO YYYY-MM-DD.
* Supported formats: DD/MM/YYYY, MM/DD/YYYY, YYYY-MM-DD, DD-MM-YYYY, DD.MM.YYYY
*/
export function parseDate(raw: string, format: string): string {
if (!raw || typeof raw !== "string") return "";
const cleaned = raw.trim();
let day: string, month: string, year: string;
// Extract parts based on separator
const parts = cleaned.split(/[/\-\.]/);
if (parts.length !== 3) return "";
switch (format) {
case "DD/MM/YYYY":
case "DD-MM-YYYY":
case "DD.MM.YYYY":
[day, month, year] = parts;
break;
case "MM/DD/YYYY":
case "MM-DD-YYYY":
[month, day, year] = parts;
break;
case "YYYY-MM-DD":
case "YYYY/MM/DD":
[year, month, day] = parts;
break;
default:
// Default to DD/MM/YYYY (French)
[day, month, year] = parts;
break;
}
// Validate
const y = parseInt(year, 10);
const m = parseInt(month, 10);
const d = parseInt(day, 10);
if (isNaN(y) || isNaN(m) || isNaN(d)) return "";
if (m < 1 || m > 12 || d < 1 || d > 31) return "";
// Handle 2-digit years
const fullYear = y < 100 ? (y > 50 ? 1900 + y : 2000 + y) : y;
return `${fullYear.toString().padStart(4, "0")}-${m.toString().padStart(2, "0")}-${d.toString().padStart(2, "0")}`;
}