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 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-10 12:36:12 +00:00
parent 9ff410e9f9
commit 41398f0f34
16 changed files with 208 additions and 96 deletions

View file

@ -41,7 +41,7 @@ export default function CategoryPieChart({ data }: CategoryPieChartProps) {
</Pie>
<Tooltip
formatter={(value) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(Number(value))
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value))
}
/>
</PieChart>

View file

@ -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 (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">

View file

@ -4,19 +4,25 @@ import type { DuplicateCheckResult } from "../../shared/types";
interface DuplicateCheckPanelProps {
result: DuplicateCheckResult;
onSkipDuplicates: () => void;
excludedIndices: Set<number>;
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 (
<div className="space-y-6">
<h2 className="text-lg font-semibold">
@ -58,28 +64,30 @@ export default function DuplicateCheckPanel({
</div>
</div>
{/* Duplicate action */}
{/* Bulk actions */}
<div className="flex gap-4 mb-4">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="duplicateAction"
checked={skipDuplicates}
onChange={onSkipDuplicates}
className="accent-[var(--primary)]"
/>
<button
type="button"
onClick={onSkipAll}
className={`px-3 py-1.5 text-sm rounded-lg border transition-colors ${
allExcluded
? "bg-[var(--primary)] text-white border-[var(--primary)]"
: "bg-[var(--card)] border-[var(--border)] hover:bg-[var(--muted)]"
}`}
>
{t("import.duplicates.skip")}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="radio"
name="duplicateAction"
checked={!skipDuplicates}
onChange={onIncludeAll}
className="accent-[var(--primary)]"
/>
</button>
<button
type="button"
onClick={onIncludeAll}
className={`px-3 py-1.5 text-sm rounded-lg border transition-colors ${
noneExcluded
? "bg-[var(--primary)] text-white border-[var(--primary)]"
: "bg-[var(--card)] border-[var(--border)] hover:bg-[var(--muted)]"
}`}
>
{t("import.duplicates.includeAll")}
</label>
</button>
</div>
{/* Duplicate table */}
@ -87,6 +95,14 @@ export default function DuplicateCheckPanel({
<table className="w-full text-sm">
<thead className="sticky top-0">
<tr className="bg-[var(--muted)]">
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--muted-foreground)] w-10">
<input
type="checkbox"
checked={noneExcluded}
onChange={() => noneExcluded ? onSkipAll() : onIncludeAll()}
className="accent-[var(--primary)]"
/>
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--muted-foreground)]">
#
</th>
@ -99,26 +115,54 @@ export default function DuplicateCheckPanel({
<th className="px-3 py-2 text-right text-xs font-medium text-[var(--muted-foreground)]">
{t("import.preview.amount")}
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--muted-foreground)]">
{t("import.source")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border)]">
{result.duplicateRows.map((row) => (
<tr
key={row.rowIndex}
className="bg-[var(--muted)]"
>
<td className="px-3 py-2 text-[var(--muted-foreground)]">
{row.rowIndex + 1}
</td>
<td className="px-3 py-2">{row.date}</td>
<td className="px-3 py-2 max-w-xs truncate">
{row.description}
</td>
<td className="px-3 py-2 text-right font-mono">
{row.amount.toFixed(2)}
</td>
</tr>
))}
{result.duplicateRows.map((row) => {
const included = !excludedIndices.has(row.rowIndex);
const isBatch = row.existingTransactionId === -1;
return (
<tr
key={row.rowIndex}
className={included ? "bg-[var(--card)]" : "bg-[var(--muted)] opacity-60"}
>
<td className="px-3 py-2">
<input
type="checkbox"
checked={included}
onChange={() => onToggleRow(row.rowIndex)}
className="accent-[var(--primary)]"
/>
</td>
<td className="px-3 py-2 text-[var(--muted-foreground)]">
{row.rowIndex + 1}
</td>
<td className="px-3 py-2">{row.date}</td>
<td className="px-3 py-2 max-w-xs truncate">
{row.description}
</td>
<td className="px-3 py-2 text-right font-mono">
{row.amount.toFixed(2)}
</td>
<td className="px-3 py-2">
<span
className={`inline-block px-2 py-0.5 text-xs rounded-full ${
isBatch
? "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"
: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
}`}
>
{isBatch
? t("import.duplicates.sourceBatch")
: t("import.duplicates.sourceDb")}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
@ -142,6 +186,11 @@ export default function DuplicateCheckPanel({
new: result.newRows.length,
duplicates: result.duplicateRows.length,
})}
{excludedIndices.size > 0 && (
<span className="text-[var(--muted-foreground)]">
{" "} {excludedIndices.size} {t("import.duplicates.skip").toLowerCase()}
</span>
)}
</p>
</div>
</div>

View file

@ -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 (
<div className="space-y-6">
@ -87,7 +86,7 @@ export default function ImportConfirmation({
<p className="text-sm text-[var(--muted-foreground)]">
{t("import.confirm.rowsSummary", {
count: rowsToImport,
skipped: skipDuplicates ? duplicateResult.duplicateRows.length : 0,
skipped: excludedCount,
})}
</p>
</div>

View file

@ -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) {
<BarChart data={data} layout="vertical" margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<XAxis
type="number"
tickFormatter={(v) => 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)"
/>
<Tooltip
formatter={(value: number | undefined) => eurFormatter(value ?? 0)}
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",

View file

@ -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)"
/>
<YAxis
tickFormatter={(v) => eurFormatter(v)}
tickFormatter={(v) => cadFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
width={80}
/>
<Tooltip
formatter={(value: number | undefined) => eurFormatter(value ?? 0)}
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
labelFormatter={(label) => formatMonth(String(label))}
contentStyle={{
backgroundColor: "var(--card)",

View file

@ -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)"
/>
<YAxis
tickFormatter={(v) => eurFormatter(v)}
tickFormatter={(v) => cadFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
width={80}
/>
<Tooltip
formatter={(value: number | undefined) => eurFormatter(value ?? 0)}
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
labelFormatter={(label) => formatMonth(String(label))}
contentStyle={{
backgroundColor: "var(--card)",

View file

@ -47,7 +47,7 @@ interface WizardState {
parsedPreview: ParsedRow[];
previewHeaders: string[];
duplicateResult: DuplicateCheckResult | null;
skipDuplicates: boolean;
excludedDuplicateIndices: Set<number>;
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<string>; files: Map<string, Set<string>> } }
@ -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 }),
};
}

View file

@ -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",

View file

@ -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",

View file

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

View file

@ -27,7 +27,8 @@ export default function ImportPage() {
executeImport,
goToStep,
reset,
setSkipDuplicates,
toggleDuplicateRow,
setSkipAllDuplicates,
} = useImportWizard();
return (
@ -116,9 +117,10 @@ export default function ImportPage() {
<div className="space-y-6">
<DuplicateCheckPanel
result={state.duplicateResult}
skipDuplicates={state.skipDuplicates}
onSkipDuplicates={() => setSkipDuplicates(true)}
onIncludeAll={() => setSkipDuplicates(false)}
excludedIndices={state.excludedDuplicateIndices}
onToggleRow={toggleDuplicateRow}
onSkipAll={() => setSkipAllDuplicates(true)}
onIncludeAll={() => setSkipAllDuplicates(false)}
/>
<WizardNavigation
onBack={() => 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}
/>
<WizardNavigation
onBack={() => goToStep("duplicate-check")}

View file

@ -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]

View file

@ -107,3 +107,8 @@ export async function updateSource(
values
);
}
export async function deleteSource(id: number): Promise<void> {
const db = await getDb();
await db.execute("DELETE FROM import_sources WHERE id = $1", [id]);
}

View file

@ -86,11 +86,31 @@ export async function deleteImportWithTransactions(
fileId: number
): Promise<number> {
const db = await getDb();
// Look up the source_id before deleting
const files = await db.select<ImportedFile[]>(
"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<Array<{ cnt: number }>>(
"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<number> {
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;
}

View file

@ -20,7 +20,8 @@ export async function insertBatch(
original_description: string;
category_id?: number | null;
supplier_id?: number | null;
}>
}>,
onProgress?: (inserted: number) => void
): Promise<number> {
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;