feat: профили импорта CSV для Т-Банка, monobank и ПриватБанка, версия 1.1.8

This commit is contained in:
ZProger
2026-09-13 06:32:36 +03:00
parent c867710f38
commit 848ff21665
9 changed files with 403 additions and 24 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "aurum-frontend",
"private": true,
"version": "1.1.7",
"version": "1.1.8",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { applyPresetMapping, detectPreset, findPreset, normalizeHeader, rowFilterRejection, type ImportMapping } from "@/lib/bankPresets";
import { parseAmount, parseCsv, parseDateWithFormat } from "@/lib/csv";
const EMPTY: ImportMapping = { date: "", amount: "", description: "", merchant: "", notes: "", category: "" };
// Header rows as the banks actually emit them (see the per-preset comments in
// lib/bankPresets.ts for where each export comes from).
const TBANK_CSV = [
'"Дата операции";"Дата платежа";"Номер карты";"Статус";"Сумма операции";"Валюта операции";"Сумма платежа";"Валюта платежа";"Кэшбэк";"Категория";"MCC";"Описание";"Бонусы (включая кэшбэк)"',
'"24.10.2018 17:04:25";"24.10.2018";"*8305";"OK";"-882,04";"RUB";"-882,04";"RUB";"";"Переводы/иб";"";"Пополнение счета Тинькофф Брокер";"0,00"',
'"25.10.2018 09:12:00";"25.10.2018";"*8305";"FAILED";"-1500,00";"RUB";"-1500,00";"RUB";"";"Супермаркеты";"5411";"Пятёрочка";"0,00"',
].join("\n");
const MONOBANK_CSV = [
// Latin "i" in "Дата i час" is deliberate — that's what the real file has.
'"Дата i час операції","Деталі операції",MCC,"Сума в валюті картки (UAH)","Сума в валюті операції",Валюта,Курс,"Сума комісій (UAH)","Сума кешбеку (UAH)","Залишок після операції"',
'"01.03.2024 12:30:45","Сільпо",5411,-250.50,-250.50,UAH,—,—,2.50,10000.00',
].join("\n");
const PRIVAT_CSV = [
"Дата;Час;Категорія;Картка;Опис операції;Сума в валюті картки;Валюта картки;Сума в валюті транзакції;Валюта транзакції;Залишок на кінець періоду;Валюта залишку",
"05.02.2024;14:05;Продукти;5168****1234;АТБ;-320,00;UAH;-320,00;UAH;1500,00;UAH",
].join("\n");
describe("normalizeHeader", () => {
it("folds case, whitespace, BOM and the Latin i", () => {
expect(normalizeHeader(" Дата i час ")).toBe("дата і час");
});
});
describe("detectPreset", () => {
it("recognises T-Bank, monobank and PrivatBank header rows", () => {
expect(detectPreset(parseCsv(TBANK_CSV)[0])?.id).toBe("tbank");
expect(detectPreset(parseCsv(MONOBANK_CSV)[0])?.id).toBe("monobank");
expect(detectPreset(parseCsv(PRIVAT_CSV)[0])?.id).toBe("privatbank");
});
it("returns null for a generic export", () => {
expect(detectPreset(["Date", "Amount", "Description"])).toBeNull();
});
});
describe("applyPresetMapping", () => {
it("maps T-Bank onto the card-currency amount and keeps the category", () => {
const headers = parseCsv(TBANK_CSV)[0];
const mapping = applyPresetMapping(findPreset("tbank")!, headers, EMPTY);
expect(mapping).toMatchObject({ date: "Дата операции", amount: "Сумма платежа", description: "Описание", category: "Категория" });
});
it("matches monobank's currency-suffixed amount header by prefix", () => {
const headers = parseCsv(MONOBANK_CSV)[0];
const mapping = applyPresetMapping(findPreset("monobank")!, headers, EMPTY);
expect(mapping.amount).toBe("Сума в валюті картки (UAH)");
expect(mapping.date).toBe("Дата i час операції");
});
it("leaves fields the preset doesn't cover on the fallback", () => {
const headers = parseCsv(PRIVAT_CSV)[0];
const mapping = applyPresetMapping(findPreset("privatbank")!, headers, { ...EMPTY, notes: "Картка" });
expect(mapping.notes).toBe("Картка");
expect(mapping.category).toBe("Категорія");
});
});
describe("rowFilterRejection", () => {
it("drops T-Bank rows whose status is not OK and reports the status", () => {
const [headers, ok, failed] = parseCsv(TBANK_CSV);
const preset = findPreset("tbank")!;
expect(rowFilterRejection(preset, headers, ok)).toBeNull();
expect(rowFilterRejection(preset, headers, failed)).toBe("FAILED");
});
it("keeps everything when the preset has no filter or the column is absent", () => {
const [headers, row] = parseCsv(MONOBANK_CSV);
expect(rowFilterRejection(findPreset("monobank")!, headers, row)).toBeNull();
expect(rowFilterRejection(findPreset("tbank")!, ["Дата"], ["01.01.2024"])).toBeNull();
expect(rowFilterRejection(null, headers, row)).toBeNull();
});
});
describe("preset formats against real sample rows", () => {
it("parses T-Bank's timestamped date and comma-decimal amount", () => {
const [, row] = parseCsv(TBANK_CSV);
const preset = findPreset("tbank")!;
expect(parseDateWithFormat(row[0], preset.dateFormat)).toBe("2018-10-24");
expect(parseAmount(row[6], preset.amountFormat)).toBe(-882.04);
});
it("parses monobank's timestamped date and dot-decimal amount", () => {
const [, row] = parseCsv(MONOBANK_CSV);
const preset = findPreset("monobank")!;
expect(parseDateWithFormat(row[0], preset.dateFormat)).toBe("2024-03-01");
expect(parseAmount(row[3], preset.amountFormat)).toBe(-250.5);
// monobank writes empty cells as an em dash — must not parse as 0.
expect(parseAmount(row[6], preset.amountFormat)).toBeNull();
});
it("parses PrivatBank's plain date and comma amount", () => {
const [, row] = parseCsv(PRIVAT_CSV);
const preset = findPreset("privatbank")!;
expect(parseDateWithFormat(row[0], preset.dateFormat)).toBe("2024-02-05");
expect(parseAmount(row[5], preset.amountFormat)).toBe(-320);
});
});
describe("parseDateWithFormat with a time suffix", () => {
it("accepts an optional trailing time in every format", () => {
expect(parseDateWithFormat("2024-01-05T09:30", "YYYY-MM-DD")).toBe("2024-01-05");
expect(parseDateWithFormat("05/01/2024 09:30:15", "DD/MM/YYYY")).toBe("2024-01-05");
expect(parseDateWithFormat("01/05/2024 9:30", "MM/DD/YYYY")).toBe("2024-01-05");
expect(parseDateWithFormat("05-01-2024 09:30", "DD-MM-YYYY")).toBe("2024-01-05");
});
it("still rejects trailing garbage that isn't a time", () => {
expect(parseDateWithFormat("05.01.2024 abc", "DD.MM.YYYY")).toBeNull();
expect(parseDateWithFormat("05.01.2024 09", "DD.MM.YYYY")).toBeNull();
});
});

View File

@@ -0,0 +1,152 @@
// Bank-specific import presets for the CSV wizard (pages/CsvImportPage.tsx).
//
// A preset is nothing more than a pre-filled answer to the wizard's "map"
// step: which column is the date, which is the amount, what the date/amount
// formats are, and — the one thing the generic mapping can't express — which
// rows to drop (a declined card payment still shows up in the export). The
// wizard itself stays bank-agnostic: presets only feed it defaults, and the
// user can still override any of them on the map screen.
//
// Only banks whose personal-account CSV export has a known, stable header
// layout are listed. Banks that export PDF/XLSX only (Sber, Kaspi, Halyk,
// maib…) go through the generic mapping via a third-party converter instead.
import type { AmountFormat, DateFormat } from "@/lib/csv";
/** Column mapping as the wizard understands it — header *names*, not
* indexes, so re-decoding the file under another encoding can re-resolve
* them. Empty string = not mapped. */
export interface ImportMapping {
date: string;
amount: string;
description: string;
merchant: string;
notes: string;
category: string;
}
/** Matches a header cell by normalised prefix — see normalizeHeader(). A
* prefix, not equality, because some banks put the account currency in the
* header itself ("Сума в валюті картки (UAH)") and it changes per card. */
export type HeaderMatcher = string[];
export interface BankPreset {
id: string;
/** Proper name, shown as-is — bank names aren't translated. */
label: string;
/** Every one of these must be present for detectPreset() to pick this
* preset. Keep it to the columns the mapping actually needs, so a bank
* adding a trailing column doesn't break detection. */
detect: HeaderMatcher[];
mapping: Partial<Record<keyof ImportMapping, HeaderMatcher>>;
dateFormat: DateFormat;
amountFormat: AmountFormat;
/** Rows whose `column` value fails `accept` are skipped before parsing —
* e.g. a "Статус" column that reads FAILED for a declined payment. */
rowFilter?: { column: HeaderMatcher; accept: (value: string) => boolean };
}
/** Lower-cased, trimmed, BOM-stripped, whitespace-collapsed header text.
* Also folds the Latin "i" into the Cyrillic "і": monobank's Ukrainian header
* literally reads "Дата i час операції" with a Latin i in the middle, and
* nobody typing a matcher by hand would guess that. */
export function normalizeHeader(header: string): string {
return header
.replace(/^\uFEFF/, "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ")
.replace(/i/g, "і");
}
function findHeader(headers: string[], matcher: HeaderMatcher): string {
const candidates = matcher.map(normalizeHeader);
return headers.find((header) => candidates.some((candidate) => normalizeHeader(header).startsWith(candidate))) ?? "";
}
export const BANK_PRESETS: readonly BankPreset[] = [
{
// Web version → Операции → «Выгрузка операций» → CSV. Semicolon-separated,
// every field quoted, comma decimal mark, dates carry a time of day.
// "Сумма платежа" is the amount in the card's own currency (a foreign
// purchase shows the original amount in "Сумма операции"), which is what
// the account balance actually moved by.
id: "tbank",
label: "Т-Банк",
detect: [["дата операции"], ["сумма платежа"], ["описание"], ["статус"]],
mapping: {
date: ["дата операции"],
amount: ["сумма платежа"],
description: ["описание"],
category: ["категория"],
},
dateFormat: "DD.MM.YYYY",
amountFormat: "comma-decimal",
rowFilter: { column: ["статус"], accept: (value) => value.trim().toUpperCase() === "OK" },
},
{
// App → card → Виписка → CSV, Ukrainian UI language. Comma-separated,
// UTF-8, dot decimal mark, empty cells written as an em dash ("—").
// The amount header embeds the card currency, hence the prefix match.
id: "monobank",
label: "monobank",
detect: [["дата і час операції"], ["деталі операції"], ["сума в валюті картки"]],
mapping: {
date: ["дата і час операції"],
amount: ["сума в валюті картки"],
description: ["деталі операції"],
},
dateFormat: "DD.MM.YYYY",
amountFormat: "dot-decimal",
},
{
// next.privat24.ua → card → Виписки → CSV. Date and time come in
// separate columns, so the plain date parses as-is; the amount in the
// card's currency is what the balance moved by.
id: "privatbank",
label: "ПриватБанк",
detect: [["дата"], ["опис операції"], ["сума в валюті картки"]],
mapping: {
date: ["дата"],
amount: ["сума в валюті картки"],
description: ["опис операції"],
category: ["категорія"],
},
dateFormat: "DD.MM.YYYY",
amountFormat: "auto",
},
];
export function findPreset(id: string): BankPreset | null {
return BANK_PRESETS.find((preset) => preset.id === id) ?? null;
}
/** The first preset whose every `detect` matcher finds a header. Order in
* BANK_PRESETS matters only if two banks ever share a header set. */
export function detectPreset(headers: string[]): BankPreset | null {
return BANK_PRESETS.find((preset) => preset.detect.every((matcher) => findHeader(headers, matcher) !== "")) ?? null;
}
/** Resolves a preset's matchers against the actual header row. Fields the
* preset doesn't cover fall back to `fallback` (the wizard's generic
* header guesses), so a preset only has to describe what it knows. */
export function applyPresetMapping(preset: BankPreset, headers: string[], fallback: ImportMapping): ImportMapping {
const resolved = { ...fallback };
(Object.keys(preset.mapping) as (keyof ImportMapping)[]).forEach((field) => {
const matcher = preset.mapping[field];
if (matcher) resolved[field] = findHeader(headers, matcher);
});
return resolved;
}
/** When `preset.rowFilter` says this row is not a real transaction, returns
* the offending cell value (for the "skipped because…" message); null means
* keep the row. Also null when the preset has no filter or its column is
* missing from the header row — an absent status column is not a reason
* to drop data. */
export function rowFilterRejection(preset: BankPreset | null, headers: string[], cells: string[]): string | null {
if (!preset?.rowFilter) return null;
const idx = headers.indexOf(findHeader(headers, preset.rowFilter.column));
if (idx < 0) return null;
const value = cells[idx] ?? "";
return preset.rowFilter.accept(value) ? null : value.trim();
}

View File

@@ -57,12 +57,17 @@ export function parseCsv(text: string): string[][] {
export const DATE_FORMATS = ["YYYY-MM-DD", "DD.MM.YYYY", "DD/MM/YYYY", "MM/DD/YYYY", "DD-MM-YYYY"] as const;
export type DateFormat = (typeof DATE_FORMATS)[number];
// Every pattern tolerates an optional trailing time of day ("24.10.2018
// 17:04:25", "2024-01-05T09:30") — bank exports routinely timestamp each
// operation (T-Bank, monobank), and the wizard only stores the calendar
// date anyway.
const TIME_SUFFIX = /(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?$/.source;
const DATE_PATTERNS: Record<DateFormat, { regex: RegExp; order: ["y" | "m" | "d", "y" | "m" | "d", "y" | "m" | "d"] }> = {
"YYYY-MM-DD": { regex: /^(\d{4})-(\d{1,2})-(\d{1,2})$/, order: ["y", "m", "d"] },
"DD.MM.YYYY": { regex: /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/, order: ["d", "m", "y"] },
"DD/MM/YYYY": { regex: /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, order: ["d", "m", "y"] },
"MM/DD/YYYY": { regex: /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, order: ["m", "d", "y"] },
"DD-MM-YYYY": { regex: /^(\d{1,2})-(\d{1,2})-(\d{4})$/, order: ["d", "m", "y"] },
"YYYY-MM-DD": { regex: new RegExp(/^(\d{4})-(\d{1,2})-(\d{1,2})/.source + TIME_SUFFIX), order: ["y", "m", "d"] },
"DD.MM.YYYY": { regex: new RegExp(/^(\d{1,2})\.(\d{1,2})\.(\d{4})/.source + TIME_SUFFIX), order: ["d", "m", "y"] },
"DD/MM/YYYY": { regex: new RegExp(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/.source + TIME_SUFFIX), order: ["d", "m", "y"] },
"MM/DD/YYYY": { regex: new RegExp(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/.source + TIME_SUFFIX), order: ["m", "d", "y"] },
"DD-MM-YYYY": { regex: new RegExp(/^(\d{1,2})-(\d{1,2})-(\d{4})/.source + TIME_SUFFIX), order: ["d", "m", "y"] },
};
/** Parses `raw` per `format`, returning an ISO "YYYY-MM-DD" string, or null

View File

@@ -264,6 +264,12 @@ const ru = {
"transactions.import.duplicateCheckFailed": "Не удалось проверить, есть ли уже такие транзакции на счёте — при импорте возможны дубли.",
"transactions.import.duplicatesFound": "Найдено похожих на уже существующие: {{count}}. По умолчанию они не импортируются.",
"transactions.import.includeDuplicates": "Всё равно импортировать их",
"transactions.import.presetLabel": "Банк / формат файла",
"transactions.import.preset.auto": "Определить автоматически",
"transactions.import.presetHint": "Готовые профили: Т-Банк, monobank, ПриватБанк — колонки и форматы подставятся сами. Выгрузки других банков импортируются через ручное сопоставление колонок.",
"transactions.import.presetDetected": "Профиль банка: {{bank}}. Колонки и форматы подставлены автоматически — проверьте и при необходимости поправьте.",
"transactions.import.presetNotDetected": "Формат банка не распознан — сопоставьте колонки вручную или выберите банк из списка.",
"transactions.import.errorRowFiltered": "операция не проведена (статус «{{value}}»)",
"transactions.allTypes": "Все типы",
"transactions.expense": "Расходы",
"transactions.income": "Доходы",
@@ -783,6 +789,12 @@ const en: Record<keyof typeof ru, string> = {
"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.import.presetLabel": "Bank / file format",
"transactions.import.preset.auto": "Detect automatically",
"transactions.import.presetHint": "Built-in profiles: T-Bank, monobank, PrivatBank — columns and formats are pre-filled. Exports from other banks go through manual column mapping.",
"transactions.import.presetDetected": "Bank profile: {{bank}}. Columns and formats were pre-filled — review and adjust if needed.",
"transactions.import.presetNotDetected": "Bank format not recognised — map the columns by hand or pick a bank from the list.",
"transactions.import.errorRowFiltered": "operation was not completed (status “{{value}}”)",
"transactions.allTypes": "All types",
"transactions.expense": "Expenses",
"transactions.income": "Income",

View File

@@ -8,6 +8,15 @@ 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,
@@ -35,14 +44,12 @@ const NONE = "";
const ENCODINGS = ["utf-8", "windows-1251", "windows-1252", "koi8-r"] as const;
type Encoding = (typeof ENCODINGS)[number];
interface Mapping {
date: string;
amount: string;
description: string;
merchant: string;
notes: string;
category: string;
}
// 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;
@@ -72,6 +79,26 @@ function guessMapping(headerRow: string[]): Mapping {
};
}
/** 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();
@@ -85,6 +112,7 @@ export function CsvImportPage() {
const [fileName, setFileName] = useState("");
const [fileBuffer, setFileBuffer] = useState<ArrayBuffer | null>(null);
const [encoding, setEncoding] = useState<Encoding>("utf-8");
const [presetChoice, setPresetChoice] = useState<string>(PRESET_AUTO);
const [mapping, setMapping] = useState<Mapping>({ date: "", amount: "", description: "", merchant: "", notes: "", category: "" });
const [dateFormat, setDateFormat] = useState<DateFormat>("YYYY-MM-DD");
const [amountFormat, setAmountFormat] = useState<AmountFormat>("auto");
@@ -105,7 +133,10 @@ export function CsvImportPage() {
return { headers: headerRow, dataRows: rest };
}, [fileBuffer, encoding]);
// Owns mapping initialisation, keyed on the header row's *contents*.
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
@@ -114,15 +145,25 @@ export function CsvImportPage() {
// 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.
// 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;
setMapping(guessMapping(headers));
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]);
}, [headersKey, presetChoice]);
const categoryLookup = useMemo(() => {
const map: Record<"income" | "expense", Map<string, number>> = { income: new Map(), expense: new Map() };
@@ -206,6 +247,15 @@ export function CsvImportPage() {
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 || "—" }) });
@@ -251,7 +301,7 @@ export function CsvImportPage() {
duplicateCount: duplicateRows.length,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step, headers, dataRows, mapping, dateFormat, amountFormat, categoryLookup, accountId, existingKeys, includeDuplicates, t]);
}, [step, headers, dataRows, mapping, dateFormat, amountFormat, categoryLookup, accountId, existingKeys, includeDuplicates, activePreset, t]);
async function handleFileSelected(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
@@ -277,11 +327,16 @@ export function CsvImportPage() {
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(guessMapping(headerRow));
setAmountFormat("auto");
setMapping(defaults.mapping);
setDateFormat(defaults.dateFormat);
setAmountFormat(defaults.amountFormat);
setIncludeDuplicates(false);
setStep("map");
}
@@ -307,6 +362,23 @@ export function CsvImportPage() {
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) => (
<div>
<Label htmlFor={id}>{t("transactions.import.presetLabel")}</Label>
<Select id={id} value={presetChoice} onChange={(event) => setPresetChoice(event.target.value)} className="sm:w-72">
<option value={PRESET_AUTO}>{t("transactions.import.preset.auto")}</option>
{BANK_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
</Select>
</div>
);
return (
<div className="space-y-5">
<div className="flex items-center gap-2">
@@ -346,6 +418,8 @@ export function CsvImportPage() {
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-text-secondary">{t("transactions.import.uploadHint")}</p>
{presetSelect("upload-preset")}
<p className="text-xs text-text-muted">{t("transactions.import.presetHint")}</p>
<Button onClick={() => fileInputRef.current?.click()} disabled={!accountId}>
<Upload size={16} />
{t("transactions.import.chooseFile")}
@@ -364,6 +438,13 @@ export function CsvImportPage() {
<span className="text-xs text-text-muted">{fileName}</span>
</CardHeader>
<CardContent className="space-y-3">
{presetSelect("map-preset")}
<p className={`text-xs ${activePreset ? "text-success" : "text-text-muted"}`}>
{activePreset
? t("transactions.import.presetDetected", { bank: activePreset.label })
: t("transactions.import.presetNotDetected")}
</p>
<div>
<Label htmlFor="map-encoding">{t("transactions.import.encodingLabel")}</Label>
<Select id="map-encoding" value={encoding} onChange={(event) => setEncoding(event.target.value as Encoding)} className="sm:w-56">