mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
fix: устранить баги в проверке дублей и смене кодировки при импорте CSV
- Кэш проверки дублей не сбрасывался после импорта: повторный импорт того же файла в течение 30 секунд (staleTime) сверялся со списком транзакций, полученным ДО первого импорта, и не находил ни одного дубля — ровно тот случай, ради которого проверка и делалась. - Кнопку импорта можно было нажать, пока проверка дублей ещё шла или шёл фоновый рефетч: дубли проходили мимо фильтра. Теперь кнопка заблокирована на время запроса, а его ошибка больше не проглатывается молча, а показывается предупреждением. - Ручная смена кодировки ломала уже выбранные колонки: заголовки перечитывались, старый выбор указывал на несуществующее имя, indexOf давал -1, и все строки молча отбрасывались как «не удалось распознать дату». Маппинг переугадывается при изменении состава заголовков. - Текст «Они не будут импортированы» противоречил включённой галочке «Всё равно импортировать их».
This commit is contained in:
@@ -15,6 +15,11 @@ function useInvalidateAfterTransactionChange() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
||||
// Without this, a second CSV import of the same date range within
|
||||
// staleTime (see main.tsx) would check for duplicates against a cached
|
||||
// list from *before* the first import finished — and find none, which
|
||||
// is exactly the case the duplicate check exists to catch.
|
||||
queryClient.invalidateQueries({ queryKey: ["transactions-duplicate-check"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["transaction-years"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] });
|
||||
|
||||
@@ -260,7 +260,8 @@ const ru = {
|
||||
"transactions.import.amountFormat.dot-decimal": "Точка (1234.56), запятая — тысячи",
|
||||
"transactions.import.amountFormat.comma-decimal": "Запятая (1234,56), точка — тысячи",
|
||||
"transactions.import.duplicateCheckLoading": "Проверяем, нет ли уже таких транзакций…",
|
||||
"transactions.import.duplicatesFound": "Найдено похожих на уже существующие: {{count}}. Они не будут импортированы.",
|
||||
"transactions.import.duplicateCheckFailed": "Не удалось проверить, есть ли уже такие транзакции на счёте — при импорте возможны дубли.",
|
||||
"transactions.import.duplicatesFound": "Найдено похожих на уже существующие: {{count}}. По умолчанию они не импортируются.",
|
||||
"transactions.import.includeDuplicates": "Всё равно импортировать их",
|
||||
"transactions.allTypes": "Все типы",
|
||||
"transactions.expense": "Расходы",
|
||||
@@ -777,7 +778,8 @@ const en: Record<keyof typeof ru, string> = {
|
||||
"transactions.import.amountFormat.dot-decimal": "Dot (1234.56), comma is thousands",
|
||||
"transactions.import.amountFormat.comma-decimal": "Comma (1234,56), dot is thousands",
|
||||
"transactions.import.duplicateCheckLoading": "Checking for transactions already on this account…",
|
||||
"transactions.import.duplicatesFound": "Found {{count}} that look like duplicates of existing transactions. They won't be imported.",
|
||||
"transactions.import.duplicateCheckFailed": "Couldn't check what's already on this account — importing may create duplicates.",
|
||||
"transactions.import.duplicatesFound": "Found {{count}} that look like duplicates of existing transactions. They're left out by default.",
|
||||
"transactions.import.includeDuplicates": "Import them anyway",
|
||||
"transactions.allTypes": "All types",
|
||||
"transactions.expense": "Expenses",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Upload } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
@@ -49,6 +49,29 @@ interface SkippedRow {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// Best-effort auto-mapping by common header names — whatever it gets wrong,
|
||||
// the user corrects on the map screen.
|
||||
const HEADER_GUESSES: Record<keyof Mapping, string[]> = {
|
||||
date: ["date", "дата"],
|
||||
amount: ["amount", "сумма"],
|
||||
description: ["description", "описание", "назначение платежа"],
|
||||
merchant: ["merchant", "payee", "получатель"],
|
||||
notes: ["notes", "заметка", "примечание"],
|
||||
category: ["category", "категория"],
|
||||
};
|
||||
|
||||
function guessMapping(headerRow: string[]): Mapping {
|
||||
const guess = (candidates: string[]) => headerRow.find((h) => candidates.includes(h.trim().toLowerCase())) ?? "";
|
||||
return {
|
||||
date: guess(HEADER_GUESSES.date),
|
||||
amount: guess(HEADER_GUESSES.amount),
|
||||
description: guess(HEADER_GUESSES.description),
|
||||
merchant: guess(HEADER_GUESSES.merchant),
|
||||
notes: guess(HEADER_GUESSES.notes),
|
||||
category: guess(HEADER_GUESSES.category),
|
||||
};
|
||||
}
|
||||
|
||||
export function CsvImportPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -82,6 +105,25 @@ export function CsvImportPage() {
|
||||
return { headers: headerRow, dataRows: rest };
|
||||
}, [fileBuffer, encoding]);
|
||||
|
||||
// Owns mapping initialisation, keyed on the header row's *contents*.
|
||||
// Changing the encoding re-decodes that row, and a column picked under the
|
||||
// previous encoding then names a header that no longer exists:
|
||||
// headers.indexOf() returns -1, the dropdown renders blank while
|
||||
// mappingComplete still reports "ready", and every row gets skipped as an
|
||||
// unparseable date. Re-guessing against the new names is the only sane
|
||||
// reading of a mapping whose columns are all gone. When the decoded
|
||||
// headers come out identical (an all-ASCII header row survives every
|
||||
// encoding here), the key doesn't change, this doesn't run, and manual
|
||||
// column picks stay put.
|
||||
// JSON.stringify, not join(): joining on a separator would read
|
||||
// ["ab","c"] and ["a","bc"] as one and the same header row.
|
||||
const headersKey = JSON.stringify(headers);
|
||||
useEffect(() => {
|
||||
if (headers.length === 0) return;
|
||||
setMapping(guessMapping(headers));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [headersKey]);
|
||||
|
||||
const categoryLookup = useMemo(() => {
|
||||
const map: Record<"income" | "expense", Map<string, number>> = { income: new Map(), expense: new Map() };
|
||||
for (const category of categories ?? []) {
|
||||
@@ -238,18 +280,7 @@ export function CsvImportPage() {
|
||||
setFileName(file.name);
|
||||
setFileBuffer(buffer);
|
||||
setEncoding(detectedEncoding);
|
||||
// Best-effort auto-mapping by common header names — the user can still
|
||||
// correct any of these on the next screen.
|
||||
const guess = (...candidates: string[]) =>
|
||||
headerRow.find((h) => candidates.includes(h.trim().toLowerCase())) ?? "";
|
||||
setMapping({
|
||||
date: guess("date", "дата"),
|
||||
amount: guess("amount", "сумма"),
|
||||
description: guess("description", "описание", "назначение платежа"),
|
||||
merchant: guess("merchant", "payee", "получатель"),
|
||||
notes: guess("notes", "заметка", "примечание"),
|
||||
category: guess("category", "категория"),
|
||||
});
|
||||
setMapping(guessMapping(headerRow));
|
||||
setAmountFormat("auto");
|
||||
setIncludeDuplicates(false);
|
||||
setStep("map");
|
||||
@@ -505,10 +536,14 @@ export function CsvImportPage() {
|
||||
{t("transactions.import.summary", { valid: valid.length, skipped: skipped.length })}
|
||||
</p>
|
||||
|
||||
{existingTransactions.isLoading && (
|
||||
{existingTransactions.isFetching && (
|
||||
<p className="text-xs text-text-muted">{t("transactions.import.duplicateCheckLoading")}</p>
|
||||
)}
|
||||
|
||||
{existingTransactions.isError && (
|
||||
<p className="text-sm text-danger">{t("transactions.import.duplicateCheckFailed")}</p>
|
||||
)}
|
||||
|
||||
{duplicateCount > 0 && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border bg-surface-2 p-3 text-xs text-text-muted">
|
||||
<label className="flex items-start gap-2">
|
||||
@@ -581,7 +616,13 @@ export function CsvImportPage() {
|
||||
<Button variant="ghost" onClick={() => setStep("map")}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
<Button disabled={valid.length === 0 || bulkCreate.isPending} onClick={() => void handleImport()}>
|
||||
{/* isFetching, not isLoading: a background refetch of the
|
||||
duplicate check still serves stale data, and importing
|
||||
against that would wave duplicates straight through. */}
|
||||
<Button
|
||||
disabled={valid.length === 0 || bulkCreate.isPending || existingTransactions.isFetching}
|
||||
onClick={() => void handleImport()}
|
||||
>
|
||||
{bulkCreate.isPending
|
||||
? t("common.saving")
|
||||
: t("transactions.import.importButton", { count: valid.length })}
|
||||
|
||||
Reference in New Issue
Block a user