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({
@@ -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;