fix: searchable category combobox, import source upsert, and intra-batch duplicate detection
Replace native <select> with a type-to-search CategoryCombobox in both the filter bar and inline table cells. Fix re-import UNIQUE constraint error by using INSERT ... ON CONFLICT upsert in createSource(). Detect duplicate rows within the same import batch using a Set key check. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d6000e191f
commit
9ff410e9f9
7 changed files with 291 additions and 58 deletions
189
src/components/shared/CategoryCombobox.tsx
Normal file
189
src/components/shared/CategoryCombobox.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import type { Category } from "../../shared/types";
|
||||||
|
|
||||||
|
interface CategoryComboboxProps {
|
||||||
|
categories: Category[];
|
||||||
|
value: number | null;
|
||||||
|
onChange: (id: number | null) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
/** Extra options shown before the category list (e.g. "All categories", "Uncategorized") */
|
||||||
|
extraOptions?: Array<{ value: string; label: string }>;
|
||||||
|
/** Called when an extra option is selected */
|
||||||
|
onExtraSelect?: (value: string) => void;
|
||||||
|
/** Currently active extra option value (for display) */
|
||||||
|
activeExtra?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryCombobox({
|
||||||
|
categories,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "",
|
||||||
|
compact = false,
|
||||||
|
extraOptions,
|
||||||
|
onExtraSelect,
|
||||||
|
activeExtra,
|
||||||
|
}: CategoryComboboxProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Build display label
|
||||||
|
const selectedCategory = categories.find((c) => c.id === value);
|
||||||
|
const displayLabel =
|
||||||
|
activeExtra != null
|
||||||
|
? extraOptions?.find((o) => o.value === activeExtra)?.label ?? ""
|
||||||
|
: selectedCategory?.name ?? "";
|
||||||
|
|
||||||
|
// Filter categories
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
const filtered = query
|
||||||
|
? categories.filter((c) => c.name.toLowerCase().includes(lowerQuery))
|
||||||
|
: categories;
|
||||||
|
|
||||||
|
const filteredExtras = extraOptions
|
||||||
|
? query
|
||||||
|
? extraOptions.filter((o) => o.label.toLowerCase().includes(lowerQuery))
|
||||||
|
: extraOptions
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const totalItems = filteredExtras.length + filtered.length;
|
||||||
|
|
||||||
|
// Scroll highlighted item into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && listRef.current) {
|
||||||
|
const el = listRef.current.children[highlightIndex] as HTMLElement | undefined;
|
||||||
|
el?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
}, [highlightIndex, open]);
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const selectItem = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index < filteredExtras.length) {
|
||||||
|
onExtraSelect?.(filteredExtras[index].value);
|
||||||
|
} else {
|
||||||
|
const cat = filtered[index - filteredExtras.length];
|
||||||
|
if (cat) onChange(cat.id);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
inputRef.current?.blur();
|
||||||
|
},
|
||||||
|
[filteredExtras, filtered, onChange, onExtraSelect]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!open) {
|
||||||
|
if (e.key === "ArrowDown" || e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
setHighlightIndex(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightIndex((i) => (i + 1) % totalItems);
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightIndex((i) => (i - 1 + totalItems) % totalItems);
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
e.preventDefault();
|
||||||
|
if (totalItems > 0) selectItem(highlightIndex);
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
inputRef.current?.blur();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const py = compact ? "py-1" : "py-2";
|
||||||
|
const px = compact ? "px-2" : "px-3";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={open ? query : displayLabel}
|
||||||
|
placeholder={placeholder || displayLabel}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setHighlightIndex(0);
|
||||||
|
if (!open) setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setOpen(true);
|
||||||
|
setQuery("");
|
||||||
|
setHighlightIndex(0);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={`w-full ${px} ${py} text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]`}
|
||||||
|
/>
|
||||||
|
{open && totalItems > 0 && (
|
||||||
|
<ul
|
||||||
|
ref={listRef}
|
||||||
|
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg"
|
||||||
|
>
|
||||||
|
{filteredExtras.map((opt, i) => (
|
||||||
|
<li
|
||||||
|
key={`extra-${opt.value}`}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => selectItem(i)}
|
||||||
|
onMouseEnter={() => setHighlightIndex(i)}
|
||||||
|
className={`${px} ${py} text-sm cursor-pointer ${
|
||||||
|
i === highlightIndex
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{filtered.map((cat, i) => {
|
||||||
|
const idx = filteredExtras.length + i;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={cat.id}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => selectItem(idx)}
|
||||||
|
onMouseEnter={() => setHighlightIndex(idx)}
|
||||||
|
className={`${px} ${py} text-sm cursor-pointer ${
|
||||||
|
idx === highlightIndex
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import type { TransactionFilters, Category, ImportSource } from "../../shared/types";
|
import type { TransactionFilters, Category, ImportSource } from "../../shared/types";
|
||||||
|
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||||
|
|
||||||
interface TransactionFilterBarProps {
|
interface TransactionFilterBarProps {
|
||||||
filters: TransactionFilters;
|
filters: TransactionFilters;
|
||||||
|
|
@ -17,6 +19,14 @@ export default function TransactionFilterBar({
|
||||||
}: TransactionFilterBarProps) {
|
}: TransactionFilterBarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const categoryExtras = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: "", label: t("transactions.filters.allCategories") },
|
||||||
|
{ value: "uncategorized", label: t("transactions.filters.uncategorized") },
|
||||||
|
],
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
const activeCount = [
|
const activeCount = [
|
||||||
filters.search,
|
filters.search,
|
||||||
filters.categoryId !== null || filters.uncategorizedOnly,
|
filters.categoryId !== null || filters.uncategorizedOnly,
|
||||||
|
|
@ -44,34 +54,34 @@ export default function TransactionFilterBar({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category */}
|
{/* Category */}
|
||||||
<select
|
<div className="min-w-[180px]">
|
||||||
value={
|
<CategoryCombobox
|
||||||
filters.uncategorizedOnly
|
categories={categories}
|
||||||
? "uncategorized"
|
value={filters.categoryId}
|
||||||
: filters.categoryId?.toString() ?? ""
|
onChange={(id) => {
|
||||||
}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
if (val === "uncategorized") {
|
|
||||||
onFilterChange("uncategorizedOnly", true);
|
|
||||||
onFilterChange("categoryId", null);
|
|
||||||
} else {
|
|
||||||
onFilterChange("uncategorizedOnly", false);
|
onFilterChange("uncategorizedOnly", false);
|
||||||
onFilterChange("categoryId", val ? Number(val) : null);
|
onFilterChange("categoryId", id);
|
||||||
|
}}
|
||||||
|
placeholder={t("transactions.filters.allCategories")}
|
||||||
|
extraOptions={categoryExtras}
|
||||||
|
activeExtra={
|
||||||
|
filters.uncategorizedOnly
|
||||||
|
? "uncategorized"
|
||||||
|
: filters.categoryId === null
|
||||||
|
? ""
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
}}
|
onExtraSelect={(val) => {
|
||||||
className="px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
if (val === "uncategorized") {
|
||||||
>
|
onFilterChange("uncategorizedOnly", true);
|
||||||
<option value="">{t("transactions.filters.allCategories")}</option>
|
onFilterChange("categoryId", null);
|
||||||
<option value="uncategorized">
|
} else {
|
||||||
{t("transactions.filters.uncategorized")}
|
onFilterChange("uncategorizedOnly", false);
|
||||||
</option>
|
onFilterChange("categoryId", null);
|
||||||
{categories.map((c) => (
|
}
|
||||||
<option key={c.id} value={c.id}>
|
}}
|
||||||
{c.name}
|
/>
|
||||||
</option>
|
</div>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Source */}
|
{/* Source */}
|
||||||
<select
|
<select
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Fragment, useState } from "react";
|
import { Fragment, useState, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronUp, ChevronDown, MessageSquare } from "lucide-react";
|
import { ChevronUp, ChevronDown, MessageSquare } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -6,6 +6,7 @@ import type {
|
||||||
TransactionSort,
|
TransactionSort,
|
||||||
Category,
|
Category,
|
||||||
} from "../../shared/types";
|
} from "../../shared/types";
|
||||||
|
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||||
|
|
||||||
interface TransactionTableProps {
|
interface TransactionTableProps {
|
||||||
rows: TransactionRow[];
|
rows: TransactionRow[];
|
||||||
|
|
@ -43,6 +44,10 @@ export default function TransactionTable({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
const [editingNotes, setEditingNotes] = useState("");
|
const [editingNotes, setEditingNotes] = useState("");
|
||||||
|
const noCategoryExtra = useMemo(
|
||||||
|
() => [{ value: "", label: t("transactions.table.noCategory") }],
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -116,25 +121,16 @@ export default function TransactionTable({
|
||||||
})}
|
})}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<select
|
<CategoryCombobox
|
||||||
value={row.category_id?.toString() ?? ""}
|
categories={categories}
|
||||||
onChange={(e) =>
|
value={row.category_id}
|
||||||
onCategoryChange(
|
onChange={(id) => onCategoryChange(row.id, id)}
|
||||||
row.id,
|
placeholder={t("transactions.table.noCategory")}
|
||||||
e.target.value ? Number(e.target.value) : null
|
compact
|
||||||
)
|
extraOptions={noCategoryExtra}
|
||||||
}
|
activeExtra={row.category_id === null ? "" : null}
|
||||||
className="w-full px-2 py-1 text-sm rounded border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
onExtraSelect={() => onCategoryChange(row.id, null)}
|
||||||
>
|
/>
|
||||||
<option value="">
|
|
||||||
{t("transactions.table.noCategory")}
|
|
||||||
</option>
|
|
||||||
{categories.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -552,17 +552,42 @@ export function useImportWizard() {
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const duplicateIndices = new Set(duplicateMatches.map((d) => d.rowIndex));
|
// Detect intra-batch duplicates (rows that appear more than once within the import)
|
||||||
|
const dbDuplicateIndices = new Set(duplicateMatches.map((d) => d.rowIndex));
|
||||||
|
const seenKeys = new Set<string>();
|
||||||
|
const batchDuplicateIndices = new Set<number>();
|
||||||
|
|
||||||
|
for (let i = 0; i < validRows.length; i++) {
|
||||||
|
if (dbDuplicateIndices.has(i)) continue; // already flagged as DB duplicate
|
||||||
|
const r = validRows[i].parsed!;
|
||||||
|
const key = `${r.date}|${r.description}|${r.amount}`;
|
||||||
|
if (seenKeys.has(key)) {
|
||||||
|
batchDuplicateIndices.add(i);
|
||||||
|
} else {
|
||||||
|
seenKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateIndices = new Set([...dbDuplicateIndices, ...batchDuplicateIndices]);
|
||||||
const newRows = validRows.filter(
|
const newRows = validRows.filter(
|
||||||
(_, i) => !duplicateIndices.has(i)
|
(_, i) => !duplicateIndices.has(i)
|
||||||
);
|
);
|
||||||
const duplicateRows = duplicateMatches.map((d) => ({
|
const duplicateRows = [
|
||||||
rowIndex: d.rowIndex,
|
...duplicateMatches.map((d) => ({
|
||||||
date: d.date,
|
rowIndex: d.rowIndex,
|
||||||
description: d.description,
|
date: d.date,
|
||||||
amount: d.amount,
|
description: d.description,
|
||||||
existingTransactionId: d.existingTransactionId,
|
amount: d.amount,
|
||||||
}));
|
existingTransactionId: d.existingTransactionId,
|
||||||
|
})),
|
||||||
|
...[...batchDuplicateIndices].map((i) => ({
|
||||||
|
rowIndex: i,
|
||||||
|
date: validRows[i].parsed!.date,
|
||||||
|
description: validRows[i].parsed!.description,
|
||||||
|
amount: validRows[i].parsed!.amount,
|
||||||
|
existingTransactionId: -1,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_DUPLICATE_RESULT",
|
type: "SET_DUPLICATE_RESULT",
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,8 @@
|
||||||
"noneFound": "No duplicates found",
|
"noneFound": "No duplicates found",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"title": "Import Confirmation",
|
"title": "Import Confirmation",
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,8 @@
|
||||||
"noneFound": "Aucun doublon détecté",
|
"noneFound": "Aucun doublon détecté",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"title": "Confirmation de l'import",
|
"title": "Confirmation de l'import",
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,15 @@ export async function createSource(
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const result = await db.execute(
|
const result = await db.execute(
|
||||||
`INSERT INTO import_sources (name, description, date_format, delimiter, encoding, column_mapping, skip_lines)
|
`INSERT INTO import_sources (name, description, date_format, delimiter, encoding, column_mapping, skip_lines)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
|
description = excluded.description,
|
||||||
|
date_format = excluded.date_format,
|
||||||
|
delimiter = excluded.delimiter,
|
||||||
|
encoding = excluded.encoding,
|
||||||
|
column_mapping = excluded.column_mapping,
|
||||||
|
skip_lines = excluded.skip_lines,
|
||||||
|
updated_at = CURRENT_TIMESTAMP`,
|
||||||
[
|
[
|
||||||
source.name,
|
source.name,
|
||||||
source.description || null,
|
source.description || null,
|
||||||
|
|
@ -45,7 +53,10 @@ export async function createSource(
|
||||||
source.skip_lines,
|
source.skip_lines,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
return result.lastInsertId as number;
|
// On conflict, lastInsertId may be 0 — look up the existing row
|
||||||
|
if (result.lastInsertId) return result.lastInsertId as number;
|
||||||
|
const existing = await getSourceByName(source.name);
|
||||||
|
return existing!.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSource(
|
export async function updateSource(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue