From 3506c2c87e2437077c1bf6826dff2c980eb3561b Mon Sep 17 00:00:00 2001 From: Le-King-Fu Date: Tue, 10 Feb 2026 01:29:23 +0000 Subject: [PATCH] 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 --- src/components/import/ImportHistoryPanel.tsx | 118 +++++++++++++++++++ src/hooks/useImportHistory.ts | 116 ++++++++++++++++++ src/hooks/useImportWizard.ts | 2 +- src/i18n/locales/en.json | 12 ++ src/i18n/locales/fr.json | 12 ++ src/pages/ImportPage.tsx | 16 ++- src/services/importedFileService.ts | 36 +++++- src/shared/types/index.ts | 4 + 8 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 src/components/import/ImportHistoryPanel.tsx create mode 100644 src/hooks/useImportHistory.ts diff --git a/src/components/import/ImportHistoryPanel.tsx b/src/components/import/ImportHistoryPanel.tsx new file mode 100644 index 0000000..c8d527e --- /dev/null +++ b/src/components/import/ImportHistoryPanel.tsx @@ -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 ( +
+
+

+ {t("import.history.title")} +

+ {state.files.length > 0 && ( + + )} +
+ + {state.error && ( +

{state.error}

+ )} + + {state.isLoading ? ( +

+ {t("common.loading")} +

+ ) : state.files.length === 0 ? ( +
+ +

+ {t("import.history.empty")} +

+
+ ) : ( +
+ + + + + + + + + + + + {state.files.map((file) => ( + + + + + + + + + ))} + +
+ {t("import.history.source")} + + {t("import.history.filename")} + + {t("import.history.date")} + + {t("import.history.rows")} + + {t("import.history.status")} + +
{file.source_name} + {file.filename} + + {new Date(file.import_date).toLocaleDateString()} + {file.row_count} + + {file.status} + + + +
+
+ )} +
+ ); +} diff --git a/src/hooks/useImportHistory.ts b/src/hooks/useImportHistory.ts new file mode 100644 index 0000000..5837941 --- /dev/null +++ b/src/hooks/useImportHistory.ts @@ -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, + }; +} diff --git a/src/hooks/useImportWizard.ts b/src/hooks/useImportWizard.ts index 76a29f6..a4d5ffd 100644 --- a/src/hooks/useImportWizard.ts +++ b/src/hooks/useImportWizard.ts @@ -535,7 +535,7 @@ export function useImportWizard() { const hash = await invoke("hash_file", { filePath: state.selectedFiles[0].file_path, }); - const existing = await existsByHash(sourceId, hash); + const existing = await existsByHash(hash); if (existing) { fileAlreadyImported = true; existingFileId = existing.id; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7ec0d4c..0687271 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -122,6 +122,18 @@ "errorMessage": "Error message", "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": { "back": "Back", "next": "Next", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index cfdaf0a..855f020 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -122,6 +122,18 @@ "errorMessage": "Message d'erreur", "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": { "back": "Retour", "next": "Suivant", diff --git a/src/pages/ImportPage.tsx b/src/pages/ImportPage.tsx index dd94c2b..0b32b5f 100644 --- a/src/pages/ImportPage.tsx +++ b/src/pages/ImportPage.tsx @@ -9,6 +9,7 @@ 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 ImportHistoryPanel from "../components/import/ImportHistoryPanel"; import { AlertCircle } from "lucide-react"; export default function ImportPage() { @@ -53,12 +54,15 @@ export default function ImportPage() { {/* Wizard steps */} {state.step === "source-list" && ( - + <> + + + )} {state.step === "source-config" && state.selectedSource && ( diff --git a/src/services/importedFileService.ts b/src/services/importedFileService.ts index 9eefd49..2b22145 100644 --- a/src/services/importedFileService.ts +++ b/src/services/importedFileService.ts @@ -1,5 +1,5 @@ import { getDb } from "./db"; -import type { ImportedFile } from "../shared/types"; +import type { ImportedFile, ImportedFileWithSource } from "../shared/types"; export async function getFilesBySourceId( sourceId: number @@ -12,13 +12,12 @@ export async function getFilesBySourceId( } export async function existsByHash( - sourceId: number, fileHash: string ): Promise { const db = await getDb(); const rows = await db.select( - "SELECT * FROM imported_files WHERE source_id = $1 AND file_hash = $2", - [sourceId, fileHash] + "SELECT * FROM imported_files WHERE file_hash = $1", + [fileHash] ); return rows.length > 0 ? rows[0] : null; } @@ -72,3 +71,32 @@ export async function updateFileStatus( [status, rowCount ?? null, notes ?? null, id] ); } + +export async function getAllImportedFiles(): Promise { + const db = await getDb(); + return db.select( + `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 { + 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 { + const db = await getDb(); + const result = await db.execute("DELETE FROM transactions"); + await db.execute("DELETE FROM imported_files"); + return result.rowsAffected; +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 5e8309f..da13e77 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -22,6 +22,10 @@ export interface ImportedFile { notes?: string; } +export interface ImportedFileWithSource extends ImportedFile { + source_name: string; +} + export interface Category { id: number; name: string;