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

View file

@ -7,7 +7,7 @@ interface ImportConfirmationProps {
config: SourceConfig; config: SourceConfig;
selectedFiles: ScannedFile[]; selectedFiles: ScannedFile[];
duplicateResult: DuplicateCheckResult; duplicateResult: DuplicateCheckResult;
skipDuplicates: boolean; excludedCount: number;
} }
export default function ImportConfirmation({ export default function ImportConfirmation({
@ -15,13 +15,12 @@ export default function ImportConfirmation({
config, config,
selectedFiles, selectedFiles,
duplicateResult, duplicateResult,
skipDuplicates, excludedCount,
}: ImportConfirmationProps) { }: ImportConfirmationProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const rowsToImport = skipDuplicates const rowsToImport =
? duplicateResult.newRows.length duplicateResult.newRows.length + duplicateResult.duplicateRows.length - excludedCount;
: duplicateResult.newRows.length + duplicateResult.duplicateRows.length;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -87,7 +86,7 @@ export default function ImportConfirmation({
<p className="text-sm text-[var(--muted-foreground)]"> <p className="text-sm text-[var(--muted-foreground)]">
{t("import.confirm.rowsSummary", { {t("import.confirm.rowsSummary", {
count: rowsToImport, count: rowsToImport,
skipped: skipDuplicates ? duplicateResult.duplicateRows.length : 0, skipped: excludedCount,
})} })}
</p> </p>
</div> </div>

View file

@ -10,8 +10,8 @@ import {
} from "recharts"; } from "recharts";
import type { CategoryBreakdownItem } from "../../shared/types"; import type { CategoryBreakdownItem } from "../../shared/types";
const eurFormatter = (value: number) => const cadFormatter = (value: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(value); new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
interface CategoryBarChartProps { interface CategoryBarChartProps {
data: CategoryBreakdownItem[]; 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 }}> <BarChart data={data} layout="vertical" margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<XAxis <XAxis
type="number" type="number"
tickFormatter={(v) => eurFormatter(v)} tickFormatter={(v) => cadFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }} tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)" stroke="var(--border)"
/> />
@ -46,7 +46,7 @@ export default function CategoryBarChart({ data }: CategoryBarChartProps) {
stroke="var(--border)" stroke="var(--border)"
/> />
<Tooltip <Tooltip
formatter={(value: number | undefined) => eurFormatter(value ?? 0)} formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
contentStyle={{ contentStyle={{
backgroundColor: "var(--card)", backgroundColor: "var(--card)",
border: "1px solid var(--border)", border: "1px solid var(--border)",

View file

@ -11,8 +11,8 @@ import {
} from "recharts"; } from "recharts";
import type { CategoryOverTimeData } from "../../shared/types"; import type { CategoryOverTimeData } from "../../shared/types";
const eurFormatter = (value: number) => const cadFormatter = (value: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(value); new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
function formatMonth(month: string): string { function formatMonth(month: string): string {
const [year, m] = month.split("-"); const [year, m] = month.split("-");
@ -47,13 +47,13 @@ export default function CategoryOverTimeChart({ data }: CategoryOverTimeChartPro
stroke="var(--border)" stroke="var(--border)"
/> />
<YAxis <YAxis
tickFormatter={(v) => eurFormatter(v)} tickFormatter={(v) => cadFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }} tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)" stroke="var(--border)"
width={80} width={80}
/> />
<Tooltip <Tooltip
formatter={(value: number | undefined) => eurFormatter(value ?? 0)} formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
labelFormatter={(label) => formatMonth(String(label))} labelFormatter={(label) => formatMonth(String(label))}
contentStyle={{ contentStyle={{
backgroundColor: "var(--card)", backgroundColor: "var(--card)",

View file

@ -10,8 +10,8 @@ import {
} from "recharts"; } from "recharts";
import type { MonthlyTrendItem } from "../../shared/types"; import type { MonthlyTrendItem } from "../../shared/types";
const eurFormatter = (value: number) => const cadFormatter = (value: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(value); new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
function formatMonth(month: string): string { function formatMonth(month: string): string {
const [year, m] = month.split("-"); const [year, m] = month.split("-");
@ -56,13 +56,13 @@ export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) {
stroke="var(--border)" stroke="var(--border)"
/> />
<YAxis <YAxis
tickFormatter={(v) => eurFormatter(v)} tickFormatter={(v) => cadFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }} tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)" stroke="var(--border)"
width={80} width={80}
/> />
<Tooltip <Tooltip
formatter={(value: number | undefined) => eurFormatter(value ?? 0)} formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
labelFormatter={(label) => formatMonth(String(label))} labelFormatter={(label) => formatMonth(String(label))}
contentStyle={{ contentStyle={{
backgroundColor: "var(--card)", backgroundColor: "var(--card)",

View file

@ -47,7 +47,7 @@ interface WizardState {
parsedPreview: ParsedRow[]; parsedPreview: ParsedRow[];
previewHeaders: string[]; previewHeaders: string[];
duplicateResult: DuplicateCheckResult | null; duplicateResult: DuplicateCheckResult | null;
skipDuplicates: boolean; excludedDuplicateIndices: Set<number>;
importReport: ImportReport | null; importReport: ImportReport | null;
importProgress: { current: number; total: number; file: string }; importProgress: { current: number; total: number; file: string };
isLoading: boolean; isLoading: boolean;
@ -68,7 +68,8 @@ type WizardAction =
| { type: "SET_EXISTING_SOURCE"; payload: ImportSource | null } | { type: "SET_EXISTING_SOURCE"; payload: ImportSource | null }
| { type: "SET_PARSED_PREVIEW"; payload: { rows: ParsedRow[]; headers: string[] } } | { type: "SET_PARSED_PREVIEW"; payload: { rows: ParsedRow[]; headers: string[] } }
| { type: "SET_DUPLICATE_RESULT"; payload: DuplicateCheckResult } | { 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_REPORT"; payload: ImportReport }
| { type: "SET_IMPORT_PROGRESS"; payload: { current: number; total: number; file: string } } | { type: "SET_IMPORT_PROGRESS"; payload: { current: number; total: number; file: string } }
| { type: "SET_CONFIGURED_SOURCES"; payload: { names: Set<string>; files: Map<string, Set<string>> } } | { type: "SET_CONFIGURED_SOURCES"; payload: { names: Set<string>; files: Map<string, Set<string>> } }
@ -97,7 +98,7 @@ const initialState: WizardState = {
parsedPreview: [], parsedPreview: [],
previewHeaders: [], previewHeaders: [],
duplicateResult: null, duplicateResult: null,
skipDuplicates: true, excludedDuplicateIndices: new Set(),
importReport: null, importReport: null,
importProgress: { current: 0, total: 0, file: "" }, importProgress: { current: 0, total: 0, file: "" },
isLoading: false, isLoading: false,
@ -134,9 +135,28 @@ function reducer(state: WizardState, action: WizardAction): WizardState {
isLoading: false, isLoading: false,
}; };
case "SET_DUPLICATE_RESULT": case "SET_DUPLICATE_RESULT":
return { ...state, duplicateResult: action.payload, isLoading: false }; return {
case "SET_SKIP_DUPLICATES": ...state,
return { ...state, skipDuplicates: action.payload }; 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": case "SET_IMPORT_REPORT":
return { ...state, importReport: action.payload, isLoading: false }; return { ...state, importReport: action.payload, isLoading: false };
case "SET_IMPORT_PROGRESS": case "SET_IMPORT_PROGRESS":
@ -621,19 +641,17 @@ export function useImportWizard() {
if (!dbSource) throw new Error("Source not found in database"); if (!dbSource) throw new Error("Source not found in database");
const sourceId = dbSource.id; const sourceId = dbSource.id;
// Determine rows to import // Determine rows to import: new rows + non-excluded duplicates
const rowsToImport = state.skipDuplicates const includedDuplicates = state.duplicateResult.duplicateRows
? state.duplicateResult.newRows .filter((d) => !state.excludedDuplicateIndices.has(d.rowIndex));
: [ const rowsToImport = [
...state.duplicateResult.newRows, ...state.duplicateResult.newRows,
...state.parsedPreview.filter( ...state.parsedPreview.filter(
(r) => (r) =>
r.parsed && r.parsed &&
state.duplicateResult!.duplicateRows.some( includedDuplicates.some((d) => d.rowIndex === r.rowIndex)
(d) => d.rowIndex === r.rowIndex ),
) ];
),
];
const validRows = rowsToImport.filter((r) => r.parsed); const validRows = rowsToImport.filter((r) => r.parsed);
const totalRows = validRows.length; const totalRows = validRows.length;
@ -687,10 +705,15 @@ export function useImportWizard() {
}; };
}); });
// Insert in batches // Insert with progress
let importedCount = 0; let importedCount = 0;
try { 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({ dispatch({
type: "SET_IMPORT_PROGRESS", type: "SET_IMPORT_PROGRESS",
@ -713,9 +736,7 @@ export function useImportWizard() {
const report: ImportReport = { const report: ImportReport = {
totalRows: state.parsedPreview.length, totalRows: state.parsedPreview.length,
importedCount, importedCount,
skippedDuplicates: state.skipDuplicates skippedDuplicates: state.excludedDuplicateIndices.size,
? state.duplicateResult.duplicateRows.length
: 0,
errorCount: errors.length, errorCount: errors.length,
categorizedCount, categorizedCount,
uncategorizedCount, uncategorizedCount,
@ -737,7 +758,7 @@ export function useImportWizard() {
}, [ }, [
state.duplicateResult, state.duplicateResult,
state.sourceConfig, state.sourceConfig,
state.skipDuplicates, state.excludedDuplicateIndices,
state.parsedPreview, state.parsedPreview,
state.selectedFiles, state.selectedFiles,
loadConfiguredSources, loadConfiguredSources,
@ -764,7 +785,9 @@ export function useImportWizard() {
executeImport, executeImport,
goToStep, goToStep,
reset, reset,
setSkipDuplicates: (v: boolean) => toggleDuplicateRow: (index: number) =>
dispatch({ type: "SET_SKIP_DUPLICATES", payload: v }), 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", "skip": "Skip duplicates",
"includeAll": "Import all", "includeAll": "Import all",
"summary": "Total: {{total}} rows — {{new}} new — {{duplicates}} duplicate(s)", "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": { "confirm": {
"title": "Import Confirmation", "title": "Import Confirmation",

View file

@ -95,7 +95,9 @@
"skip": "Ignorer les doublons", "skip": "Ignorer les doublons",
"includeAll": "Tout importer", "includeAll": "Tout importer",
"summary": "Total : {{total}} lignes — {{new}} nouvelles — {{duplicates}} doublon(s)", "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": { "confirm": {
"title": "Confirmation de l'import", "title": "Confirmation de l'import",

View file

@ -5,7 +5,7 @@ import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryPieChart from "../components/dashboard/CategoryPieChart"; import CategoryPieChart from "../components/dashboard/CategoryPieChart";
import RecentTransactionsList from "../components/dashboard/RecentTransactionsList"; 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() { export default function DashboardPage() {
const { t } = useTranslation(); const { t } = useTranslation();

View file

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

View file

@ -99,6 +99,7 @@ export async function getRecentTransactions(
c.name AS category_name, c.color AS category_color c.name AS category_name, c.color AS category_color
FROM transactions t FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id LEFT JOIN categories c ON t.category_id = c.id
WHERE t.amount < 0
ORDER BY t.date DESC, t.id DESC ORDER BY t.date DESC, t.id DESC
LIMIT $1`, LIMIT $1`,
[limit] [limit]

View file

@ -107,3 +107,8 @@ export async function updateSource(
values 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 fileId: number
): Promise<number> { ): Promise<number> {
const db = await getDb(); 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( const result = await db.execute(
"DELETE FROM transactions WHERE file_id = $1", "DELETE FROM transactions WHERE file_id = $1",
[fileId] [fileId]
); );
await db.execute("DELETE FROM imported_files WHERE 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; return result.rowsAffected;
} }
@ -98,5 +118,6 @@ export async function deleteAllImportsWithTransactions(): Promise<number> {
const db = await getDb(); const db = await getDb();
const result = await db.execute("DELETE FROM transactions"); const result = await db.execute("DELETE FROM transactions");
await db.execute("DELETE FROM imported_files"); await db.execute("DELETE FROM imported_files");
await db.execute("DELETE FROM import_sources");
return result.rowsAffected; return result.rowsAffected;
} }

View file

@ -20,7 +20,8 @@ export async function insertBatch(
original_description: string; original_description: string;
category_id?: number | null; category_id?: number | null;
supplier_id?: number | null; supplier_id?: number | null;
}> }>,
onProgress?: (inserted: number) => void
): Promise<number> { ): Promise<number> {
const db = await getDb(); const db = await getDb();
let insertedCount = 0; let insertedCount = 0;
@ -41,6 +42,13 @@ export async function insertBatch(
] ]
); );
insertedCount++; insertedCount++;
if (onProgress && insertedCount % 10 === 0) {
onProgress(insertedCount);
}
}
if (onProgress && insertedCount % 10 !== 0) {
onProgress(insertedCount);
} }
return insertedCount; return insertedCount;