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:
parent
9ff410e9f9
commit
41398f0f34
16 changed files with 208 additions and 96 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)]">
|
||||
|
|
|
|||
|
|
@ -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,14 +115,28 @@ 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) => (
|
||||
{result.duplicateRows.map((row) => {
|
||||
const included = !excludedIndices.has(row.rowIndex);
|
||||
const isBatch = row.existingTransactionId === -1;
|
||||
return (
|
||||
<tr
|
||||
key={row.rowIndex}
|
||||
className="bg-[var(--muted)]"
|
||||
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>
|
||||
|
|
@ -117,8 +147,22 @@ export default function DuplicateCheckPanel({
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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,17 +641,15 @@ 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
|
||||
: [
|
||||
// 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 &&
|
||||
state.duplicateResult!.duplicateRows.some(
|
||||
(d) => d.rowIndex === r.rowIndex
|
||||
)
|
||||
includedDuplicates.some((d) => d.rowIndex === r.rowIndex)
|
||||
),
|
||||
];
|
||||
|
||||
|
|
@ -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 }),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue