feat: add import history panel with delete and cross-source duplicate detection

Make duplicate file detection cross-source by removing sourceId from
existsByHash query. Add import history table below source list with
per-import and delete-all functionality.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-10 01:29:23 +00:00
parent 9ea8314149
commit 3506c2c87e
8 changed files with 305 additions and 11 deletions

View file

@ -0,0 +1,118 @@
import { useTranslation } from "react-i18next";
import { Trash2, Inbox } from "lucide-react";
import { useImportHistory } from "../../hooks/useImportHistory";
interface ImportHistoryPanelProps {
onChanged?: () => void;
}
export default function ImportHistoryPanel({
onChanged,
}: ImportHistoryPanelProps) {
const { t } = useTranslation();
const { state, handleDelete, handleDeleteAll } = useImportHistory(onChanged);
return (
<div className="mt-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">
{t("import.history.title")}
</h2>
{state.files.length > 0 && (
<button
onClick={handleDeleteAll}
disabled={state.isDeleting}
className="px-3 py-1.5 text-sm rounded-lg bg-[var(--negative)] text-white hover:opacity-90 disabled:opacity-50"
>
{t("import.history.deleteAll")}
</button>
)}
</div>
{state.error && (
<p className="text-sm text-[var(--negative)] mb-2">{state.error}</p>
)}
{state.isLoading ? (
<p className="text-sm text-[var(--muted-foreground)]">
{t("common.loading")}
</p>
) : state.files.length === 0 ? (
<div className="bg-[var(--card)] rounded-xl p-8 border-2 border-dashed border-[var(--border)] text-center">
<Inbox
size={32}
className="mx-auto mb-3 text-[var(--muted-foreground)]"
/>
<p className="text-[var(--muted-foreground)]">
{t("import.history.empty")}
</p>
</div>
) : (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left text-[var(--muted-foreground)]">
<th className="px-4 py-2 font-medium">
{t("import.history.source")}
</th>
<th className="px-4 py-2 font-medium">
{t("import.history.filename")}
</th>
<th className="px-4 py-2 font-medium">
{t("import.history.date")}
</th>
<th className="px-4 py-2 font-medium text-right">
{t("import.history.rows")}
</th>
<th className="px-4 py-2 font-medium">
{t("import.history.status")}
</th>
<th className="px-4 py-2 w-10" />
</tr>
</thead>
<tbody>
{state.files.map((file) => (
<tr
key={file.id}
className="border-b border-[var(--border)] last:border-b-0"
>
<td className="px-4 py-2">{file.source_name}</td>
<td className="px-4 py-2 truncate max-w-[200px]">
{file.filename}
</td>
<td className="px-4 py-2 whitespace-nowrap">
{new Date(file.import_date).toLocaleDateString()}
</td>
<td className="px-4 py-2 text-right">{file.row_count}</td>
<td className="px-4 py-2">
<span
className={
file.status === "completed"
? "text-[var(--positive)]"
: file.status === "error"
? "text-[var(--negative)]"
: "text-[var(--muted-foreground)]"
}
>
{file.status}
</span>
</td>
<td className="px-4 py-2">
<button
onClick={() => handleDelete(file.id, file.row_count)}
disabled={state.isDeleting}
className="p-1 rounded hover:bg-[var(--muted)] text-[var(--negative)] disabled:opacity-50"
title={t("common.delete")}
>
<Trash2 size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,116 @@
import { useReducer, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { ImportedFileWithSource } from "../shared/types";
import {
getAllImportedFiles,
deleteImportWithTransactions,
deleteAllImportsWithTransactions,
} from "../services/importedFileService";
interface ImportHistoryState {
files: ImportedFileWithSource[];
isLoading: boolean;
isDeleting: boolean;
error: string | null;
}
type ImportHistoryAction =
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_DELETING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null }
| { type: "SET_FILES"; payload: ImportedFileWithSource[] };
const initialState: ImportHistoryState = {
files: [],
isLoading: false,
isDeleting: false,
error: null,
};
function reducer(
state: ImportHistoryState,
action: ImportHistoryAction
): ImportHistoryState {
switch (action.type) {
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_DELETING":
return { ...state, isDeleting: action.payload };
case "SET_ERROR":
return { ...state, error: action.payload };
case "SET_FILES":
return { ...state, files: action.payload };
default:
return state;
}
}
export function useImportHistory(onChanged?: () => void) {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const { t } = useTranslation();
const loadHistory = useCallback(async () => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const files = await getAllImportedFiles();
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_FILES", payload: files });
} catch (err) {
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: String(err) });
} finally {
if (fetchId === fetchIdRef.current) {
dispatch({ type: "SET_LOADING", payload: false });
}
}
}, []);
const handleDelete = useCallback(
async (fileId: number, rowCount: number) => {
const ok = confirm(
t("import.history.deleteConfirm", { count: rowCount })
);
if (!ok) return;
dispatch({ type: "SET_DELETING", payload: true });
try {
await deleteImportWithTransactions(fileId);
await loadHistory();
onChanged?.();
} catch (err) {
dispatch({ type: "SET_ERROR", payload: String(err) });
} finally {
dispatch({ type: "SET_DELETING", payload: false });
}
},
[loadHistory, onChanged, t]
);
const handleDeleteAll = useCallback(async () => {
const ok = confirm(t("import.history.deleteAllConfirm"));
if (!ok) return;
dispatch({ type: "SET_DELETING", payload: true });
try {
await deleteAllImportsWithTransactions();
await loadHistory();
onChanged?.();
} catch (err) {
dispatch({ type: "SET_ERROR", payload: String(err) });
} finally {
dispatch({ type: "SET_DELETING", payload: false });
}
}, [loadHistory, onChanged, t]);
useEffect(() => {
loadHistory();
}, [loadHistory]);
return {
state,
loadHistory,
handleDelete,
handleDeleteAll,
};
}

View file

@ -535,7 +535,7 @@ export function useImportWizard() {
const hash = await invoke<string>("hash_file", { const hash = await invoke<string>("hash_file", {
filePath: state.selectedFiles[0].file_path, filePath: state.selectedFiles[0].file_path,
}); });
const existing = await existsByHash(sourceId, hash); const existing = await existsByHash(hash);
if (existing) { if (existing) {
fileAlreadyImported = true; fileAlreadyImported = true;
existingFileId = existing.id; existingFileId = existing.id;

View file

@ -122,6 +122,18 @@
"errorMessage": "Error message", "errorMessage": "Error message",
"done": "Done" "done": "Done"
}, },
"history": {
"title": "Import History",
"empty": "No imports yet.",
"deleteAll": "Delete All",
"deleteConfirm": "Delete this import and its {{count}} transaction(s)?",
"deleteAllConfirm": "Delete ALL imports and their transactions? This cannot be undone.",
"source": "Source",
"filename": "File",
"date": "Date",
"rows": "Rows",
"status": "Status"
},
"wizard": { "wizard": {
"back": "Back", "back": "Back",
"next": "Next", "next": "Next",

View file

@ -122,6 +122,18 @@
"errorMessage": "Message d'erreur", "errorMessage": "Message d'erreur",
"done": "Terminé" "done": "Terminé"
}, },
"history": {
"title": "Historique des imports",
"empty": "Aucun import pour le moment.",
"deleteAll": "Tout supprimer",
"deleteConfirm": "Supprimer cet import et ses {{count}} transaction(s) ?",
"deleteAllConfirm": "Supprimer TOUS les imports et leurs transactions ? Cette action est irréversible.",
"source": "Source",
"filename": "Fichier",
"date": "Date",
"rows": "Lignes",
"status": "Statut"
},
"wizard": { "wizard": {
"back": "Retour", "back": "Retour",
"next": "Suivant", "next": "Suivant",

View file

@ -9,6 +9,7 @@ import ImportConfirmation from "../components/import/ImportConfirmation";
import ImportProgress from "../components/import/ImportProgress"; import ImportProgress from "../components/import/ImportProgress";
import ImportReportPanel from "../components/import/ImportReportPanel"; import ImportReportPanel from "../components/import/ImportReportPanel";
import WizardNavigation from "../components/import/WizardNavigation"; import WizardNavigation from "../components/import/WizardNavigation";
import ImportHistoryPanel from "../components/import/ImportHistoryPanel";
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
export default function ImportPage() { export default function ImportPage() {
@ -53,12 +54,15 @@ export default function ImportPage() {
{/* Wizard steps */} {/* Wizard steps */}
{state.step === "source-list" && ( {state.step === "source-list" && (
<>
<SourceList <SourceList
sources={state.scannedSources} sources={state.scannedSources}
configuredSourceNames={state.configuredSourceNames} configuredSourceNames={state.configuredSourceNames}
importedFileHashes={state.importedFilesBySource} importedFileHashes={state.importedFilesBySource}
onSelectSource={selectSource} onSelectSource={selectSource}
/> />
<ImportHistoryPanel onChanged={refreshFolder} />
</>
)} )}
{state.step === "source-config" && state.selectedSource && ( {state.step === "source-config" && state.selectedSource && (

View file

@ -1,5 +1,5 @@
import { getDb } from "./db"; import { getDb } from "./db";
import type { ImportedFile } from "../shared/types"; import type { ImportedFile, ImportedFileWithSource } from "../shared/types";
export async function getFilesBySourceId( export async function getFilesBySourceId(
sourceId: number sourceId: number
@ -12,13 +12,12 @@ export async function getFilesBySourceId(
} }
export async function existsByHash( export async function existsByHash(
sourceId: number,
fileHash: string fileHash: string
): Promise<ImportedFile | null> { ): Promise<ImportedFile | null> {
const db = await getDb(); const db = await getDb();
const rows = await db.select<ImportedFile[]>( const rows = await db.select<ImportedFile[]>(
"SELECT * FROM imported_files WHERE source_id = $1 AND file_hash = $2", "SELECT * FROM imported_files WHERE file_hash = $1",
[sourceId, fileHash] [fileHash]
); );
return rows.length > 0 ? rows[0] : null; return rows.length > 0 ? rows[0] : null;
} }
@ -72,3 +71,32 @@ export async function updateFileStatus(
[status, rowCount ?? null, notes ?? null, id] [status, rowCount ?? null, notes ?? null, id]
); );
} }
export async function getAllImportedFiles(): Promise<ImportedFileWithSource[]> {
const db = await getDb();
return db.select<ImportedFileWithSource[]>(
`SELECT f.*, s.name AS source_name
FROM imported_files f
JOIN import_sources s ON s.id = f.source_id
ORDER BY f.import_date DESC`
);
}
export async function deleteImportWithTransactions(
fileId: number
): Promise<number> {
const db = await getDb();
const result = await db.execute(
"DELETE FROM transactions WHERE file_id = $1",
[fileId]
);
await db.execute("DELETE FROM imported_files WHERE id = $1", [fileId]);
return result.rowsAffected;
}
export async function deleteAllImportsWithTransactions(): Promise<number> {
const db = await getDb();
const result = await db.execute("DELETE FROM transactions");
await db.execute("DELETE FROM imported_files");
return result.rowsAffected;
}

View file

@ -22,6 +22,10 @@ export interface ImportedFile {
notes?: string; notes?: string;
} }
export interface ImportedFileWithSource extends ImportedFile {
source_name: string;
}
export interface Category { export interface Category {
id: number; id: number;
name: string; name: string;