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 })}

- {existingTransactions.isLoading && ( + {existingTransactions.isFetching && (

{t("transactions.import.duplicateCheckLoading")}

)} + {existingTransactions.isError && ( +

{t("transactions.import.duplicateCheckFailed")}

+ )} + {duplicateCount > 0 && (