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:
parent
9ea8314149
commit
3506c2c87e
8 changed files with 305 additions and 11 deletions
118
src/components/import/ImportHistoryPanel.tsx
Normal file
118
src/components/import/ImportHistoryPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/hooks/useImportHistory.ts
Normal file
116
src/hooks/useImportHistory.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -535,7 +535,7 @@ export function useImportWizard() {
|
|||
const hash = await invoke<string>("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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" && (
|
||||
<SourceList
|
||||
sources={state.scannedSources}
|
||||
configuredSourceNames={state.configuredSourceNames}
|
||||
importedFileHashes={state.importedFilesBySource}
|
||||
onSelectSource={selectSource}
|
||||
/>
|
||||
<>
|
||||
<SourceList
|
||||
sources={state.scannedSources}
|
||||
configuredSourceNames={state.configuredSourceNames}
|
||||
importedFileHashes={state.importedFilesBySource}
|
||||
onSelectSource={selectSource}
|
||||
/>
|
||||
<ImportHistoryPanel onChanged={refreshFolder} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.step === "source-config" && state.selectedSource && (
|
||||
|
|
|
|||
|
|
@ -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<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]
|
||||
"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<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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ export interface ImportedFile {
|
|||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ImportedFileWithSource extends ImportedFile {
|
||||
source_name: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue