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:
parent
801404ca21
commit
49e0bd2c94
30 changed files with 3054 additions and 8 deletions
79
src-tauri/Cargo.lock
generated
79
src-tauri/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"opener:default",
|
||||
"sql:default",
|
||||
"sql:allow-execute",
|
||||
"sql:allow-select"
|
||||
"sql:allow-select",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
209
src-tauri/src/commands/fs_commands.rs
Normal file
209
src-tauri/src/commands/fs_commands.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src-tauri/src/commands/mod.rs
Normal file
3
src-tauri/src/commands/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod fs_commands;
|
||||
|
||||
pub use fs_commands::*;
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
163
src/components/import/ColumnMappingEditor.tsx
Normal file
163
src/components/import/ColumnMappingEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
src/components/import/DuplicateCheckPanel.tsx
Normal file
149
src/components/import/DuplicateCheckPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
src/components/import/FilePreviewTable.tsx
Normal file
102
src/components/import/FilePreviewTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/components/import/ImportConfirmation.tsx
Normal file
98
src/components/import/ImportConfirmation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/components/import/ImportFolderConfig.tsx
Normal file
64
src/components/import/ImportFolderConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/import/ImportProgress.tsx
Normal file
55
src/components/import/ImportProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/components/import/ImportReportPanel.tsx
Normal file
127
src/components/import/ImportReportPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/import/SourceCard.tsx
Normal file
55
src/components/import/SourceCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
230
src/components/import/SourceConfigPanel.tsx
Normal file
230
src/components/import/SourceConfigPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/components/import/SourceList.tsx
Normal file
64
src/components/import/SourceList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/components/import/WizardNavigation.tsx
Normal file
65
src/components/import/WizardNavigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
708
src/hooks/useImportWizard.ts
Normal file
708
src/hooks/useImportWizard.ts
Normal 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 }),
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
76
src/services/categorizationService.ts
Normal file
76
src/services/categorizationService.ts
Normal 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
10
src/services/db.ts
Normal 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;
|
||||
}
|
||||
98
src/services/importSourceService.ts
Normal file
98
src/services/importSourceService.ts
Normal 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
|
||||
);
|
||||
}
|
||||
61
src/services/importedFileService.ts
Normal file
61
src/services/importedFileService.ts
Normal 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]
|
||||
);
|
||||
}
|
||||
91
src/services/transactionService.ts
Normal file
91
src/services/transactionService.ts
Normal 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;
|
||||
}
|
||||
32
src/services/userPreferenceService.ts
Normal file
32
src/services/userPreferenceService.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
26
src/utils/amountParser.ts
Normal 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
47
src/utils/dateParser.ts
Normal 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")}`;
|
||||
}
|
||||
Loading…
Reference in a new issue