From 41398f0f345f41fad16714a3d65437810a833c2e Mon Sep 17 00:00:00 2001 From: Le-King-Fu Date: Tue, 10 Feb 2026 12:36:12 +0000 Subject: [PATCH] fix: CAD currency, real-time import progress, per-row duplicate control, orphaned sources cleanup, dashboard expenses-only - Change all currency formatters from EUR/fr-FR to CAD/en-CA - Add onProgress callback to insertBatch for real-time progress bar updates - Replace binary skip/include duplicates with per-row checkboxes and type badges (DB/batch) - Clean up orphaned import_sources when deleting imports with no remaining files - Filter dashboard recent transactions to show expenses only Co-Authored-By: Claude Opus 4.6 --- src/components/dashboard/CategoryPieChart.tsx | 2 +- .../dashboard/RecentTransactionsList.tsx | 2 +- src/components/import/DuplicateCheckPanel.tsx | 129 ++++++++++++------ src/components/import/ImportConfirmation.tsx | 11 +- src/components/reports/CategoryBarChart.tsx | 8 +- .../reports/CategoryOverTimeChart.tsx | 8 +- src/components/reports/MonthlyTrendsChart.tsx | 8 +- src/hooks/useImportWizard.ts | 77 +++++++---- src/i18n/locales/en.json | 4 +- src/i18n/locales/fr.json | 4 +- src/pages/DashboardPage.tsx | 2 +- src/pages/ImportPage.tsx | 12 +- src/services/dashboardService.ts | 1 + src/services/importSourceService.ts | 5 + src/services/importedFileService.ts | 21 +++ src/services/transactionService.ts | 10 +- 16 files changed, 208 insertions(+), 96 deletions(-) diff --git a/src/components/dashboard/CategoryPieChart.tsx b/src/components/dashboard/CategoryPieChart.tsx index 4b5e396..e7cf7ee 100644 --- a/src/components/dashboard/CategoryPieChart.tsx +++ b/src/components/dashboard/CategoryPieChart.tsx @@ -41,7 +41,7 @@ export default function CategoryPieChart({ data }: CategoryPieChartProps) { - new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(Number(value)) + new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value)) } /> diff --git a/src/components/dashboard/RecentTransactionsList.tsx b/src/components/dashboard/RecentTransactionsList.tsx index ca79987..20948c8 100644 --- a/src/components/dashboard/RecentTransactionsList.tsx +++ b/src/components/dashboard/RecentTransactionsList.tsx @@ -17,7 +17,7 @@ export default function RecentTransactionsList({ transactions }: RecentTransacti ); } - const fmt = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }); + const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }); return (
diff --git a/src/components/import/DuplicateCheckPanel.tsx b/src/components/import/DuplicateCheckPanel.tsx index cbd63ee..c2d57b4 100644 --- a/src/components/import/DuplicateCheckPanel.tsx +++ b/src/components/import/DuplicateCheckPanel.tsx @@ -4,19 +4,25 @@ import type { DuplicateCheckResult } from "../../shared/types"; interface DuplicateCheckPanelProps { result: DuplicateCheckResult; - onSkipDuplicates: () => void; + excludedIndices: Set; + onToggleRow: (index: number) => void; + onSkipAll: () => void; onIncludeAll: () => void; - skipDuplicates: boolean; } export default function DuplicateCheckPanel({ result, - onSkipDuplicates, + excludedIndices, + onToggleRow, + onSkipAll, onIncludeAll, - skipDuplicates, }: DuplicateCheckPanelProps) { const { t } = useTranslation(); + const allExcluded = result.duplicateRows.length > 0 && + result.duplicateRows.every((d) => excludedIndices.has(d.rowIndex)); + const noneExcluded = result.duplicateRows.every((d) => !excludedIndices.has(d.rowIndex)); + return (

@@ -58,28 +64,30 @@ export default function DuplicateCheckPanel({

- {/* Duplicate action */} + {/* Bulk actions */}
-
{/* Duplicate table */} @@ -87,6 +95,14 @@ export default function DuplicateCheckPanel({ + @@ -99,26 +115,54 @@ export default function DuplicateCheckPanel({ + - {result.duplicateRows.map((row) => ( - - - - - - - ))} + {result.duplicateRows.map((row) => { + const included = !excludedIndices.has(row.rowIndex); + const isBatch = row.existingTransactionId === -1; + return ( + + + + + + + + + ); + })}
+ noneExcluded ? onSkipAll() : onIncludeAll()} + className="accent-[var(--primary)]" + /> + # {t("import.preview.amount")} + {t("import.source")} +
- {row.rowIndex + 1} - {row.date} - {row.description} - - {row.amount.toFixed(2)} -
+ onToggleRow(row.rowIndex)} + className="accent-[var(--primary)]" + /> + + {row.rowIndex + 1} + {row.date} + {row.description} + + {row.amount.toFixed(2)} + + + {isBatch + ? t("import.duplicates.sourceBatch") + : t("import.duplicates.sourceDb")} + +
@@ -142,6 +186,11 @@ export default function DuplicateCheckPanel({ new: result.newRows.length, duplicates: result.duplicateRows.length, })} + {excludedIndices.size > 0 && ( + + {" "}— {excludedIndices.size} {t("import.duplicates.skip").toLowerCase()} + + )}

diff --git a/src/components/import/ImportConfirmation.tsx b/src/components/import/ImportConfirmation.tsx index c3c32b8..8e763ca 100644 --- a/src/components/import/ImportConfirmation.tsx +++ b/src/components/import/ImportConfirmation.tsx @@ -7,7 +7,7 @@ interface ImportConfirmationProps { config: SourceConfig; selectedFiles: ScannedFile[]; duplicateResult: DuplicateCheckResult; - skipDuplicates: boolean; + excludedCount: number; } export default function ImportConfirmation({ @@ -15,13 +15,12 @@ export default function ImportConfirmation({ config, selectedFiles, duplicateResult, - skipDuplicates, + excludedCount, }: ImportConfirmationProps) { const { t } = useTranslation(); - const rowsToImport = skipDuplicates - ? duplicateResult.newRows.length - : duplicateResult.newRows.length + duplicateResult.duplicateRows.length; + const rowsToImport = + duplicateResult.newRows.length + duplicateResult.duplicateRows.length - excludedCount; return (
@@ -87,7 +86,7 @@ export default function ImportConfirmation({

{t("import.confirm.rowsSummary", { count: rowsToImport, - skipped: skipDuplicates ? duplicateResult.duplicateRows.length : 0, + skipped: excludedCount, })}

diff --git a/src/components/reports/CategoryBarChart.tsx b/src/components/reports/CategoryBarChart.tsx index d6195db..b97101f 100644 --- a/src/components/reports/CategoryBarChart.tsx +++ b/src/components/reports/CategoryBarChart.tsx @@ -10,8 +10,8 @@ import { } from "recharts"; import type { CategoryBreakdownItem } from "../../shared/types"; -const eurFormatter = (value: number) => - new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(value); +const cadFormatter = (value: number) => + new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value); interface CategoryBarChartProps { data: CategoryBreakdownItem[]; @@ -34,7 +34,7 @@ export default function CategoryBarChart({ data }: CategoryBarChartProps) { eurFormatter(v)} + tickFormatter={(v) => cadFormatter(v)} tick={{ fill: "var(--muted-foreground)", fontSize: 12 }} stroke="var(--border)" /> @@ -46,7 +46,7 @@ export default function CategoryBarChart({ data }: CategoryBarChartProps) { stroke="var(--border)" /> eurFormatter(value ?? 0)} + formatter={(value: number | undefined) => cadFormatter(value ?? 0)} contentStyle={{ backgroundColor: "var(--card)", border: "1px solid var(--border)", diff --git a/src/components/reports/CategoryOverTimeChart.tsx b/src/components/reports/CategoryOverTimeChart.tsx index bcf7993..f969a89 100644 --- a/src/components/reports/CategoryOverTimeChart.tsx +++ b/src/components/reports/CategoryOverTimeChart.tsx @@ -11,8 +11,8 @@ import { } from "recharts"; import type { CategoryOverTimeData } from "../../shared/types"; -const eurFormatter = (value: number) => - new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(value); +const cadFormatter = (value: number) => + new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value); function formatMonth(month: string): string { const [year, m] = month.split("-"); @@ -47,13 +47,13 @@ export default function CategoryOverTimeChart({ data }: CategoryOverTimeChartPro stroke="var(--border)" /> eurFormatter(v)} + tickFormatter={(v) => cadFormatter(v)} tick={{ fill: "var(--muted-foreground)", fontSize: 12 }} stroke="var(--border)" width={80} /> eurFormatter(value ?? 0)} + formatter={(value: number | undefined) => cadFormatter(value ?? 0)} labelFormatter={(label) => formatMonth(String(label))} contentStyle={{ backgroundColor: "var(--card)", diff --git a/src/components/reports/MonthlyTrendsChart.tsx b/src/components/reports/MonthlyTrendsChart.tsx index 3d7cd44..6921680 100644 --- a/src/components/reports/MonthlyTrendsChart.tsx +++ b/src/components/reports/MonthlyTrendsChart.tsx @@ -10,8 +10,8 @@ import { } from "recharts"; import type { MonthlyTrendItem } from "../../shared/types"; -const eurFormatter = (value: number) => - new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(value); +const cadFormatter = (value: number) => + new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value); function formatMonth(month: string): string { const [year, m] = month.split("-"); @@ -56,13 +56,13 @@ export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) { stroke="var(--border)" /> eurFormatter(v)} + tickFormatter={(v) => cadFormatter(v)} tick={{ fill: "var(--muted-foreground)", fontSize: 12 }} stroke="var(--border)" width={80} /> eurFormatter(value ?? 0)} + formatter={(value: number | undefined) => cadFormatter(value ?? 0)} labelFormatter={(label) => formatMonth(String(label))} contentStyle={{ backgroundColor: "var(--card)", diff --git a/src/hooks/useImportWizard.ts b/src/hooks/useImportWizard.ts index abb841e..74ca12d 100644 --- a/src/hooks/useImportWizard.ts +++ b/src/hooks/useImportWizard.ts @@ -47,7 +47,7 @@ interface WizardState { parsedPreview: ParsedRow[]; previewHeaders: string[]; duplicateResult: DuplicateCheckResult | null; - skipDuplicates: boolean; + excludedDuplicateIndices: Set; importReport: ImportReport | null; importProgress: { current: number; total: number; file: string }; isLoading: boolean; @@ -68,7 +68,8 @@ type WizardAction = | { 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: "TOGGLE_DUPLICATE_ROW"; payload: number } + | { type: "SET_SKIP_ALL_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; files: Map> } } @@ -97,7 +98,7 @@ const initialState: WizardState = { parsedPreview: [], previewHeaders: [], duplicateResult: null, - skipDuplicates: true, + excludedDuplicateIndices: new Set(), importReport: null, importProgress: { current: 0, total: 0, file: "" }, isLoading: false, @@ -134,9 +135,28 @@ function reducer(state: WizardState, action: WizardAction): WizardState { isLoading: false, }; case "SET_DUPLICATE_RESULT": - return { ...state, duplicateResult: action.payload, isLoading: false }; - case "SET_SKIP_DUPLICATES": - return { ...state, skipDuplicates: action.payload }; + return { + ...state, + duplicateResult: action.payload, + excludedDuplicateIndices: new Set(action.payload.duplicateRows.map((d) => d.rowIndex)), + isLoading: false, + }; + case "TOGGLE_DUPLICATE_ROW": { + const next = new Set(state.excludedDuplicateIndices); + if (next.has(action.payload)) { + next.delete(action.payload); + } else { + next.add(action.payload); + } + return { ...state, excludedDuplicateIndices: next }; + } + case "SET_SKIP_ALL_DUPLICATES": + return { + ...state, + excludedDuplicateIndices: action.payload + ? new Set(state.duplicateResult?.duplicateRows.map((d) => d.rowIndex) ?? []) + : new Set(), + }; case "SET_IMPORT_REPORT": return { ...state, importReport: action.payload, isLoading: false }; case "SET_IMPORT_PROGRESS": @@ -621,19 +641,17 @@ export function useImportWizard() { 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 - ) - ), - ]; + // Determine rows to import: new rows + non-excluded duplicates + const includedDuplicates = state.duplicateResult.duplicateRows + .filter((d) => !state.excludedDuplicateIndices.has(d.rowIndex)); + const rowsToImport = [ + ...state.duplicateResult.newRows, + ...state.parsedPreview.filter( + (r) => + r.parsed && + includedDuplicates.some((d) => d.rowIndex === r.rowIndex) + ), + ]; const validRows = rowsToImport.filter((r) => r.parsed); const totalRows = validRows.length; @@ -687,10 +705,15 @@ export function useImportWizard() { }; }); - // Insert in batches + // Insert with progress let importedCount = 0; try { - importedCount = await insertBatch(transactions); + importedCount = await insertBatch(transactions, (inserted) => { + dispatch({ + type: "SET_IMPORT_PROGRESS", + payload: { current: inserted, total: totalRows, file: state.selectedFiles[0]?.filename || "" }, + }); + }); dispatch({ type: "SET_IMPORT_PROGRESS", @@ -713,9 +736,7 @@ export function useImportWizard() { const report: ImportReport = { totalRows: state.parsedPreview.length, importedCount, - skippedDuplicates: state.skipDuplicates - ? state.duplicateResult.duplicateRows.length - : 0, + skippedDuplicates: state.excludedDuplicateIndices.size, errorCount: errors.length, categorizedCount, uncategorizedCount, @@ -737,7 +758,7 @@ export function useImportWizard() { }, [ state.duplicateResult, state.sourceConfig, - state.skipDuplicates, + state.excludedDuplicateIndices, state.parsedPreview, state.selectedFiles, loadConfiguredSources, @@ -764,7 +785,9 @@ export function useImportWizard() { executeImport, goToStep, reset, - setSkipDuplicates: (v: boolean) => - dispatch({ type: "SET_SKIP_DUPLICATES", payload: v }), + toggleDuplicateRow: (index: number) => + dispatch({ type: "TOGGLE_DUPLICATE_ROW", payload: index }), + setSkipAllDuplicates: (skipAll: boolean) => + dispatch({ type: "SET_SKIP_ALL_DUPLICATES", payload: skipAll }), }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2fec211..15f0873 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -95,7 +95,9 @@ "skip": "Skip duplicates", "includeAll": "Import all", "summary": "Total: {{total}} rows — {{new}} new — {{duplicates}} duplicate(s)", - "withinBatch": "Duplicate within imported files" + "withinBatch": "Duplicate within imported files", + "sourceDb": "Existing", + "sourceBatch": "Within batch" }, "confirm": { "title": "Import Confirmation", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 457ee09..23759b3 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -95,7 +95,9 @@ "skip": "Ignorer les doublons", "includeAll": "Tout importer", "summary": "Total : {{total}} lignes — {{new}} nouvelles — {{duplicates}} doublon(s)", - "withinBatch": "Doublon entre fichiers importés" + "withinBatch": "Doublon entre fichiers importés", + "sourceDb": "Existant", + "sourceBatch": "Entre fichiers" }, "confirm": { "title": "Confirmation de l'import", diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index e4e5c81..5c9720e 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -5,7 +5,7 @@ import PeriodSelector from "../components/dashboard/PeriodSelector"; import CategoryPieChart from "../components/dashboard/CategoryPieChart"; import RecentTransactionsList from "../components/dashboard/RecentTransactionsList"; -const fmt = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }); +const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }); export default function DashboardPage() { const { t } = useTranslation(); diff --git a/src/pages/ImportPage.tsx b/src/pages/ImportPage.tsx index 0b32b5f..3df61c5 100644 --- a/src/pages/ImportPage.tsx +++ b/src/pages/ImportPage.tsx @@ -27,7 +27,8 @@ export default function ImportPage() { executeImport, goToStep, reset, - setSkipDuplicates, + toggleDuplicateRow, + setSkipAllDuplicates, } = useImportWizard(); return ( @@ -116,9 +117,10 @@ export default function ImportPage() {
setSkipDuplicates(true)} - onIncludeAll={() => setSkipDuplicates(false)} + excludedIndices={state.excludedDuplicateIndices} + onToggleRow={toggleDuplicateRow} + onSkipAll={() => setSkipAllDuplicates(true)} + onIncludeAll={() => setSkipAllDuplicates(false)} /> goToStep("file-preview")} @@ -136,7 +138,7 @@ export default function ImportPage() { config={state.sourceConfig} selectedFiles={state.selectedFiles} duplicateResult={state.duplicateResult} - skipDuplicates={state.skipDuplicates} + excludedCount={state.excludedDuplicateIndices.size} /> goToStep("duplicate-check")} diff --git a/src/services/dashboardService.ts b/src/services/dashboardService.ts index 25aac81..b6eb9a0 100644 --- a/src/services/dashboardService.ts +++ b/src/services/dashboardService.ts @@ -99,6 +99,7 @@ export async function getRecentTransactions( c.name AS category_name, c.color AS category_color FROM transactions t LEFT JOIN categories c ON t.category_id = c.id + WHERE t.amount < 0 ORDER BY t.date DESC, t.id DESC LIMIT $1`, [limit] diff --git a/src/services/importSourceService.ts b/src/services/importSourceService.ts index dc47278..16e1e84 100644 --- a/src/services/importSourceService.ts +++ b/src/services/importSourceService.ts @@ -107,3 +107,8 @@ export async function updateSource( values ); } + +export async function deleteSource(id: number): Promise { + const db = await getDb(); + await db.execute("DELETE FROM import_sources WHERE id = $1", [id]); +} diff --git a/src/services/importedFileService.ts b/src/services/importedFileService.ts index 2b22145..cf02ccf 100644 --- a/src/services/importedFileService.ts +++ b/src/services/importedFileService.ts @@ -86,11 +86,31 @@ export async function deleteImportWithTransactions( fileId: number ): Promise { const db = await getDb(); + + // Look up the source_id before deleting + const files = await db.select( + "SELECT source_id FROM imported_files WHERE id = $1", + [fileId] + ); + const sourceId = files.length > 0 ? files[0].source_id : null; + const result = await db.execute( "DELETE FROM transactions WHERE file_id = $1", [fileId] ); await db.execute("DELETE FROM imported_files WHERE id = $1", [fileId]); + + // Clean up orphaned source if no files remain + if (sourceId) { + const remaining = await db.select>( + "SELECT COUNT(*) AS cnt FROM imported_files WHERE source_id = $1", + [sourceId] + ); + if (remaining[0]?.cnt === 0) { + await db.execute("DELETE FROM import_sources WHERE id = $1", [sourceId]); + } + } + return result.rowsAffected; } @@ -98,5 +118,6 @@ export async function deleteAllImportsWithTransactions(): Promise { const db = await getDb(); const result = await db.execute("DELETE FROM transactions"); await db.execute("DELETE FROM imported_files"); + await db.execute("DELETE FROM import_sources"); return result.rowsAffected; } diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index 0f1c331..39a453a 100644 --- a/src/services/transactionService.ts +++ b/src/services/transactionService.ts @@ -20,7 +20,8 @@ export async function insertBatch( original_description: string; category_id?: number | null; supplier_id?: number | null; - }> + }>, + onProgress?: (inserted: number) => void ): Promise { const db = await getDb(); let insertedCount = 0; @@ -41,6 +42,13 @@ export async function insertBatch( ] ); insertedCount++; + if (onProgress && insertedCount % 10 === 0) { + onProgress(insertedCount); + } + } + + if (onProgress && insertedCount % 10 !== 0) { + onProgress(insertedCount); } return insertedCount;