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>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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)]">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue