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"; import { Button } from "@/components/ui/Button"; import { Label, Select } from "@/components/ui/Input"; import { useAccounts } from "@/hooks/useAccounts"; import { useCategories } from "@/hooks/useCategories"; import { useBulkCreateTransactions, useTransactionsForDuplicateCheck } from "@/hooks/useTransactions"; import { translateCategoryName } from "@/lib/categoryLabels"; import { BANK_PRESETS, applyPresetMapping, detectPreset, findPreset, rowFilterRejection, type BankPreset, type ImportMapping, } from "@/lib/bankPresets"; import { AMOUNT_FORMATS, DATE_FORMATS, parseAmount, parseCsv, parseDateWithFormat, transactionDedupeKey, type AmountFormat, type DateFormat, } from "@/lib/csv"; import { formatCurrency } from "@/lib/format"; import { ApiError } from "@/api/client"; import { useTranslation } from "@/lib/i18n"; import type { TransactionInput } from "@/types"; type Step = "upload" | "map" | "preview"; const NONE = ""; // WHATWG encoding labels TextDecoder understands. UTF-8 covers most modern // exports; the rest are here because bank CSVs — especially older or // Russian-bank ones — are routinely still in a legacy Windows codepage, and // decoding those as UTF-8 silently turns every non-ASCII character into // mojibake instead of failing loudly. const ENCODINGS = ["utf-8", "windows-1251", "windows-1252", "koi8-r"] as const; type Encoding = (typeof ENCODINGS)[number]; // Header names, not indexes — see lib/bankPresets.ts's ImportMapping. type Mapping = ImportMapping; // "auto" lets detectPreset() pick a bank profile from the header row; a // preset id pins one regardless of what the headers look like. const PRESET_AUTO = "auto"; interface SkippedRow { row: number; 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), }; } /** The bank profile in force for a given header row: the pinned one when * the user picked a bank, otherwise whatever the headers match (or null — * plain generic mapping). Pure, so both the file handler and the * header-change effect below resolve it the same way. */ function resolvePreset(headers: string[], presetChoice: string): BankPreset | null { return presetChoice === PRESET_AUTO ? detectPreset(headers) : findPreset(presetChoice); } /** Map-step defaults for a header row: the preset's answers where it has * them, the generic header guesses everywhere else. */ function resolveDefaults(headers: string[], preset: BankPreset | null): { mapping: Mapping; dateFormat: DateFormat; amountFormat: AmountFormat } { const guessed = guessMapping(headers); if (!preset) return { mapping: guessed, dateFormat: "YYYY-MM-DD", amountFormat: "auto" }; return { mapping: applyPresetMapping(preset, headers, guessed), dateFormat: preset.dateFormat, amountFormat: preset.amountFormat, }; } export function CsvImportPage() { const { t } = useTranslation(); const navigate = useNavigate(); const { data: accounts } = useAccounts(); const { data: categories } = useCategories(); const bulkCreate = useBulkCreateTransactions(); const fileInputRef = useRef(null); const [accountId, setAccountId] = useState(""); const [step, setStep] = useState("upload"); const [fileName, setFileName] = useState(""); const [fileBuffer, setFileBuffer] = useState(null); const [encoding, setEncoding] = useState("utf-8"); const [presetChoice, setPresetChoice] = useState(PRESET_AUTO); const [mapping, setMapping] = useState({ date: "", amount: "", description: "", merchant: "", notes: "", category: "" }); const [dateFormat, setDateFormat] = useState("YYYY-MM-DD"); const [amountFormat, setAmountFormat] = useState("auto"); const [includeDuplicates, setIncludeDuplicates] = useState(false); const [parseError, setParseError] = useState(null); const [importError, setImportError] = useState(null); const [createdCount, setCreatedCount] = useState(null); // Re-decoded whenever `encoding` changes, so picking a different encoding // on the map screen (because the auto-detected guess came out wrong) // re-parses the same upload without asking the user to re-select the file. const { headers, dataRows } = useMemo(() => { if (!fileBuffer) return { headers: [] as string[], dataRows: [] as string[][] }; const text = new TextDecoder(encoding).decode(fileBuffer); const rows = parseCsv(text); if (rows.length < 2) return { headers: [] as string[], dataRows: [] as string[][] }; const [headerRow, ...rest] = rows; return { headers: headerRow, dataRows: rest }; }, [fileBuffer, encoding]); const activePreset = useMemo(() => resolvePreset(headers, presetChoice), [headers, presetChoice]); // Owns mapping initialisation, keyed on the header row's *contents* and // the chosen bank profile. // 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. Switching the bank profile re-runs it on // purpose: the whole point of picking a bank is to replace the mapping. // 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; const preset = resolvePreset(headers, presetChoice); const defaults = resolveDefaults(headers, preset); setMapping(defaults.mapping); // Formats are only overwritten when a bank profile dictates them; with // no profile, a date format the user picked by hand survives an // encoding switch (the fresh-file reset lives in handleFileSelected). if (preset) { setDateFormat(defaults.dateFormat); setAmountFormat(defaults.amountFormat); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [headersKey, presetChoice]); const categoryLookup = useMemo(() => { const map: Record<"income" | "expense", Map> = { income: new Map(), expense: new Map() }; for (const category of categories ?? []) { map[category.kind].set(category.name.trim().toLowerCase(), category.id); map[category.kind].set(translateCategoryName(category.name).trim().toLowerCase(), category.id); } return map; }, [categories]); function resolveType(amount: number): "income" | "expense" { return amount < 0 ? "expense" : "income"; } /** First non-empty raw value under `headerName`, shown next to a mapping * dropdown so the user can confirm they picked the right column before * committing to a full preview — a bank's own header names (or a header * row that's just "Column1", "Column2"…) aren't always self-explanatory. */ function sampleValue(headerName: string): string { if (!headerName) return ""; const idx = headers.indexOf(headerName); if (idx < 0) return ""; const row = dataRows.find((cells) => (cells[idx] ?? "").trim() !== ""); return (row?.[idx] ?? "").trim(); } // Bounds the duplicate-check query (see useTransactionsForDuplicateCheck // below) to the date range actually present in the file, instead of every // transaction ever recorded on the account. const dateBounds = useMemo(() => { if (step !== "preview" || !mapping.date) return null; const dateIdx = headers.indexOf(mapping.date); if (dateIdx < 0) return null; let min: string | null = null; let max: string | null = null; for (const cells of dataRows) { const iso = parseDateWithFormat(cells[dateIdx] ?? "", dateFormat); if (!iso) continue; if (min === null || iso < min) min = iso; if (max === null || iso > max) max = iso; } return min && max ? { min, max } : null; }, [step, headers, dataRows, mapping.date, dateFormat]); const existingTransactions = useTransactionsForDuplicateCheck( accountId ? Number(accountId) : null, dateBounds?.min ?? null, dateBounds?.max ?? null ); // Keyed on date+type+amount+description — see lib/csv.ts's // transactionDedupeKey doc comment for why those fields and not an ID. const existingKeys = useMemo(() => { const keys = new Set(); for (const tx of existingTransactions.data ?? []) { keys.add(transactionDedupeKey(tx.date, tx.type, tx.amount, tx.description)); } return keys; }, [existingTransactions.data]); const { valid, skipped, duplicateCount } = useMemo(() => { if (step !== "preview") return { valid: [] as TransactionInput[], skipped: [] as SkippedRow[], duplicateCount: 0 }; const dateIdx = headers.indexOf(mapping.date); const amountIdx = headers.indexOf(mapping.amount); const descIdx = headers.indexOf(mapping.description); const merchantIdx = mapping.merchant ? headers.indexOf(mapping.merchant) : -1; const notesIdx = mapping.notes ? headers.indexOf(mapping.notes) : -1; const categoryIdx = mapping.category ? headers.indexOf(mapping.category) : -1; const freshRows: TransactionInput[] = []; const duplicateRows: TransactionInput[] = []; const skippedRows: SkippedRow[] = []; dataRows.forEach((cells, index) => { const rowNumber = index + 2; // header is row 1 const rawDate = cells[dateIdx] ?? ""; const rawAmount = cells[amountIdx] ?? ""; const rawDescription = (cells[descIdx] ?? "").trim(); const rawMerchant = merchantIdx >= 0 ? (cells[merchantIdx] ?? "").trim() : ""; const rawNotes = notesIdx >= 0 ? (cells[notesIdx] ?? "").trim() : ""; const rawCategory = categoryIdx >= 0 ? (cells[categoryIdx] ?? "").trim() : ""; // Bank-profile row filter first (e.g. T-Bank's "Статус" = FAILED): // a declined payment parses perfectly well as a date + amount, and // nothing downstream could tell it apart from a real expense. const rejectedStatus = rowFilterRejection(activePreset, headers, cells); if (rejectedStatus !== null) { skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorRowFiltered", { value: rejectedStatus || "—" }) }); return; } const isoDate = parseDateWithFormat(rawDate, dateFormat); if (!isoDate) { skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorBadDate", { value: rawDate || "—" }) }); return; } const amount = parseAmount(rawAmount, amountFormat); if (amount === null || amount === 0) { skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorBadAmount", { value: rawAmount || "—" }) }); return; } const description = rawDescription || rawMerchant; if (!description) { skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorNoDescription") }); return; } const type = resolveType(amount); const categoryId = rawCategory ? categoryLookup[type].get(rawCategory.toLowerCase()) ?? null : null; const candidate: TransactionInput = { account_id: Number(accountId), category_id: categoryId, transfer_account_id: null, type, amount: Math.abs(amount).toFixed(2), description, merchant: rawMerchant || null, notes: rawNotes || null, date: isoDate, }; const key = transactionDedupeKey(candidate.date, candidate.type, candidate.amount, candidate.description); if (existingKeys.has(key)) { duplicateRows.push(candidate); } else { freshRows.push(candidate); } }); return { valid: includeDuplicates ? [...freshRows, ...duplicateRows] : freshRows, skipped: skippedRows, duplicateCount: duplicateRows.length, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [step, headers, dataRows, mapping, dateFormat, amountFormat, categoryLookup, accountId, existingKeys, includeDuplicates, activePreset, t]); async function handleFileSelected(event: React.ChangeEvent) { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; setParseError(null); const buffer = await file.arrayBuffer(); // Invalid-UTF-8 byte sequences almost always mean a Cyrillic bank export // in the classic Windows codepage — a far better first guess than // staying on UTF-8 and showing every letter as mojibake. The user can // still override this from the dropdown on the next screen either way. let detectedEncoding: Encoding = "utf-8"; try { new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch { detectedEncoding = "windows-1251"; } const rows = parseCsv(new TextDecoder(detectedEncoding).decode(buffer)); if (rows.length < 2) { setParseError(t("transactions.import.errorEmptyFile")); return; } const [headerRow] = rows; // Full reset for a fresh file — the header-change effect above skips // files whose header row is byte-identical to the previous one, so // formats from the last import can't be left over here. const defaults = resolveDefaults(headerRow, resolvePreset(headerRow, presetChoice)); setFileName(file.name); setFileBuffer(buffer); setEncoding(detectedEncoding); setMapping(defaults.mapping); setDateFormat(defaults.dateFormat); setAmountFormat(defaults.amountFormat); setIncludeDuplicates(false); setStep("map"); } function startOver() { setStep("upload"); setFileName(""); setFileBuffer(null); setEncoding("utf-8"); setCreatedCount(null); setImportError(null); } async function handleImport() { setImportError(null); try { const result = await bulkCreate.mutateAsync(valid); setCreatedCount(result.created); } catch (error) { setImportError(error instanceof ApiError ? error.message : t("transactions.import.importError")); } } const mappingComplete = Boolean(mapping.date && mapping.amount && mapping.description); // Same control on the upload and map steps: picking the bank up front is // the "I know what this file is" path, changing it on the map step is the // "auto-detect guessed wrong" path. const presetSelect = (id: string) => (
); return (

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

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

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

{step === "upload" && ( {t("transactions.import.uploadTitle")}

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

{presetSelect("upload-preset")}

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

{!accountId &&

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

} {parseError &&

{parseError}

}
)} {step === "map" && ( {t("transactions.import.mapTitle")} {fileName} {presetSelect("map-preset")}

{activePreset ? t("transactions.import.presetDetected", { bank: activePreset.label }) : t("transactions.import.presetNotDetected")}

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

{mapping.date && (

{t("transactions.import.sampleValueLabel", { value: sampleValue(mapping.date) || "—" })}

)}

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

{mapping.amount && (

{t("transactions.import.sampleValueLabel", { value: sampleValue(mapping.amount) || "—" })}

)}
{mapping.description && (

{t("transactions.import.sampleValueLabel", { value: sampleValue(mapping.description) || "—" })}

)}
{mapping.merchant && (

{t("transactions.import.sampleValueLabel", { value: sampleValue(mapping.merchant) || "—" })}

)}
{mapping.notes && (

{t("transactions.import.sampleValueLabel", { value: sampleValue(mapping.notes) || "—" })}

)}

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

{mapping.category && (

{t("transactions.import.sampleValueLabel", { value: sampleValue(mapping.category) || "—" })}

)}
)} {step === "preview" && ( {t("transactions.import.previewTitle")} {createdCount !== null ? (

{t("transactions.import.importSuccess", { count: createdCount })}

) : ( <>

{t("transactions.import.summary", { valid: valid.length, skipped: skipped.length })}

{existingTransactions.isFetching && (

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

)} {existingTransactions.isError && (

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

)} {duplicateCount > 0 && (
)} {valid.length > 0 && (
{valid.slice(0, 20).map((item, index) => ( ))}
{t("transactions.form.dateLabel")} {t("transactions.form.descriptionLabel")} {t("transactions.form.categoryLabel")} {t("transactions.form.amountLabel")}
{item.date} {item.description} {categories?.find((c) => c.id === item.category_id) ? translateCategoryName(categories.find((c) => c.id === item.category_id)!.name) : "—"} {item.type === "expense" ? "-" : "+"} {formatCurrency(item.amount)}
{valid.length > 20 && (

{t("transactions.import.andMore", { count: valid.length - 20 })}

)}
)} {skipped.length > 0 && (
{skipped.map((row) => (

{t("transactions.import.skippedRow", { row: row.row, reason: row.reason })}

))}
)} {importError &&

{importError}

}
{/* isFetching, not isLoading: a background refetch of the duplicate check still serves stale data, and importing against that would wave duplicates straight through. */}
)}
)}
); }