From eba8add92f0c29819bd27b22ad915f857e7e6f79 Mon Sep 17 00:00:00 2001
From: ZProger
Date: Thu, 10 Sep 2026 20:16:00 +0300
Subject: [PATCH] =?UTF-8?q?fix:=20=D1=83=D1=81=D1=82=D1=80=D0=B0=D0=BD?=
=?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B1=D0=B0=D0=B3=D0=B8=20=D0=B2=20=D0=BF?=
=?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B5=20=D0=B4=D1=83=D0=B1?=
=?UTF-8?q?=D0=BB=D0=B5=D0=B9=20=D0=B8=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B5=20?=
=?UTF-8?q?=D0=BA=D0=BE=D0=B4=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=BF?=
=?UTF-8?q?=D1=80=D0=B8=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=B5=20CSV?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Кэш проверки дублей не сбрасывался после импорта: повторный импорт
того же файла в течение 30 секунд (staleTime) сверялся со списком
транзакций, полученным ДО первого импорта, и не находил ни одного
дубля — ровно тот случай, ради которого проверка и делалась.
- Кнопку импорта можно было нажать, пока проверка дублей ещё шла или
шёл фоновый рефетч: дубли проходили мимо фильтра. Теперь кнопка
заблокирована на время запроса, а его ошибка больше не проглатывается
молча, а показывается предупреждением.
- Ручная смена кодировки ломала уже выбранные колонки: заголовки
перечитывались, старый выбор указывал на несуществующее имя,
indexOf давал -1, и все строки молча отбрасывались как «не удалось
распознать дату». Маппинг переугадывается при изменении состава
заголовков.
- Текст «Они не будут импортированы» противоречил включённой галочке
«Всё равно импортировать их».
---
frontend/src/hooks/useTransactions.ts | 5 ++
frontend/src/lib/i18n.ts | 6 ++-
frontend/src/pages/CsvImportPage.tsx | 71 +++++++++++++++++++++------
3 files changed, 65 insertions(+), 17 deletions(-)
diff --git a/frontend/src/hooks/useTransactions.ts b/frontend/src/hooks/useTransactions.ts
index 86d5add..50bb002 100644
--- a/frontend/src/hooks/useTransactions.ts
+++ b/frontend/src/hooks/useTransactions.ts
@@ -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"] });
diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts
index 49d2a15..c715dd3 100644
--- a/frontend/src/lib/i18n.ts
+++ b/frontend/src/lib/i18n.ts
@@ -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 = {
"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",
diff --git a/frontend/src/pages/CsvImportPage.tsx b/frontend/src/pages/CsvImportPage.tsx
index 8d20586..0fc82b9 100644
--- a/frontend/src/pages/CsvImportPage.tsx
+++ b/frontend/src/pages/CsvImportPage.tsx
@@ -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 = {
+ 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> = { 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 })}