From 19f5cfa4297f1be8d17c873b168865e56ad440cd Mon Sep 17 00:00:00 2001 From: ZProger Date: Tue, 25 Aug 2026 02:44:30 +0300 Subject: [PATCH] feat: import transactions from a CSV bank statement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth of the four ideas picked from the Maybe/Sure feature audit, and the last one queued up. A 3-step wizard (account -> column mapping -> preview) at /transactions/import, parsed entirely client-side (new lib/csv.ts — delimiter auto-detect, 5 date formats, thousands/decimal-separator-aware amounts) and submitted through a new all-or-nothing POST /transactions/bulk endpoint, mirroring backup restore's validate-everything-before-touching- anything approach. Co-Authored-By: Claude Sonnet 5 --- backend/app/api/routes/transactions.py | 30 +- backend/app/schemas/transaction.py | 13 + backend/tests/test_transactions.py | 38 +++ frontend/package.json | 2 +- frontend/src/App.tsx | 2 + frontend/src/api/transactions.ts | 6 + frontend/src/hooks/useTransactions.ts | 9 + frontend/src/lib/csv.ts | 106 ++++++ frontend/src/lib/i18n.ts | 62 ++++ frontend/src/pages/CsvImportPage.tsx | 426 ++++++++++++++++++++++++ frontend/src/pages/TransactionsPage.tsx | 20 +- 11 files changed, 706 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/csv.ts create mode 100644 frontend/src/pages/CsvImportPage.tsx diff --git a/backend/app/api/routes/transactions.py b/backend/app/api/routes/transactions.py index 4aca192..fbbb2c3 100644 --- a/backend/app/api/routes/transactions.py +++ b/backend/app/api/routes/transactions.py @@ -11,7 +11,14 @@ from app.models.category import Category from app.models.enums import CategoryKind, TransactionType from app.models.tag import Tag from app.models.transaction import Transaction -from app.schemas.transaction import TransactionCreate, TransactionPage, TransactionRead, TransactionUpdate +from app.schemas.transaction import ( + TransactionBulkCreate, + TransactionBulkCreateResult, + TransactionCreate, + TransactionPage, + TransactionRead, + TransactionUpdate, +) router = APIRouter(prefix="/transactions", tags=["transactions"]) @@ -154,6 +161,27 @@ async def create_transaction(payload: TransactionCreate, session: AsyncSession = return refreshed.scalar_one() +@router.post("/bulk", response_model=TransactionBulkCreateResult, status_code=201) +async def bulk_create_transactions( + payload: TransactionBulkCreate, session: AsyncSession = Depends(get_session) +) -> TransactionBulkCreateResult: + """CSV import lands here — see schemas.TransactionBulkCreate. All rows + are validated before any is added, so a bad row 400s the whole request + instead of leaving a half-imported statement behind.""" + for item in payload.items: + await _ensure_category_matches_type(session, item.category_id, item.type) + + transactions = [] + for item in payload.items: + transaction = Transaction(**item.model_dump(exclude={"tag_ids"})) + transaction.tags = await _resolve_tags(session, item.tag_ids) + transactions.append(transaction) + + session.add_all(transactions) + await session.commit() + return TransactionBulkCreateResult(created=len(transactions)) + + @router.patch("/{transaction_id}", response_model=TransactionRead) async def update_transaction( transaction_id: int, payload: TransactionUpdate, session: AsyncSession = Depends(get_session) diff --git a/backend/app/schemas/transaction.py b/backend/app/schemas/transaction.py index 907b2bb..0c29c9c 100644 --- a/backend/app/schemas/transaction.py +++ b/backend/app/schemas/transaction.py @@ -78,3 +78,16 @@ class TransactionPage(BaseModel): total: int page: int page_size: int + + +class TransactionBulkCreate(BaseModel): + """CSV import (see routes/transactions.py's /bulk): the frontend parses + the file and maps its columns client-side, then sends already-shaped + rows here. All-or-nothing — same failure semantics as backup restore, so + a single bad row never leaves a partial import behind.""" + + items: list[TransactionCreate] = Field(min_length=1, max_length=5000) + + +class TransactionBulkCreateResult(BaseModel): + created: int diff --git a/backend/tests/test_transactions.py b/backend/tests/test_transactions.py index c16469d..5290a7e 100644 --- a/backend/tests/test_transactions.py +++ b/backend/tests/test_transactions.py @@ -16,6 +16,44 @@ from httpx import AsyncClient from tests.helpers import money, txn_payload as _txn +async def test_bulk_create_transactions(client: AsyncClient, account_id, categories): + groceries = categories["Groceries"]["id"] + resp = await client.post( + "/transactions/bulk", + json={ + "items": [ + _txn(account_id, category_id=groceries, description="csv row 1", date="2026-01-01"), + _txn(account_id, category_id=groceries, description="csv row 2", date="2026-01-02"), + ] + }, + ) + assert resp.status_code == 201 + assert resp.json()["created"] == 2 + + listed = await client.get("/transactions", params={"year": 2026, "month": 1}) + assert listed.json()["total"] == 2 + + +async def test_bulk_create_is_all_or_nothing(client: AsyncClient, account_id, categories): + groceries = categories["Groceries"]["id"] + salary = categories["Salary"]["id"] + resp = await client.post( + "/transactions/bulk", + json={ + "items": [ + _txn(account_id, category_id=groceries, description="good row"), + # An expense using an income category — rejected, and it + # should take the whole batch down with it. + _txn(account_id, category_id=salary, description="bad row"), + ] + }, + ) + assert resp.status_code == 400 + + listed = await client.get("/transactions") + assert listed.json()["total"] == 0 + + async def test_create_expense_rejects_income_category(client: AsyncClient, account_id, categories): income_category = categories["Salary"] resp = await client.post( diff --git a/frontend/package.json b/frontend/package.json index 0f7e24e..0c78088 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "aurum-frontend", "private": true, - "version": "0.25.0", + "version": "0.26.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a5b54a7..24caf73 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { AdvicePage } from "@/pages/AdvicePage"; import { BudgetPage } from "@/pages/BudgetPage"; import { CashFlowPage } from "@/pages/CashFlowPage"; import { CategoriesPage } from "@/pages/CategoriesPage"; +import { CsvImportPage } from "@/pages/CsvImportPage"; import { DashboardPage } from "@/pages/DashboardPage"; import { GoalsPage } from "@/pages/GoalsPage"; import { NetWorthPage } from "@/pages/NetWorthPage"; @@ -50,6 +51,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/transactions.ts b/frontend/src/api/transactions.ts index bdee9e1..42bd59d 100644 --- a/frontend/src/api/transactions.ts +++ b/frontend/src/api/transactions.ts @@ -37,6 +37,12 @@ export function createTransaction(input: TransactionInput) { return api.post("/transactions", input); } +/** CSV import — see pages/CsvImportPage.tsx. All-or-nothing on the backend: + * either every row is created, or (on a validation error) none are. */ +export function bulkCreateTransactions(items: TransactionInput[]) { + return api.post<{ created: number }>("/transactions/bulk", { items }); +} + export function updateTransaction(id: number, input: Partial) { return api.patch(`/transactions/${id}`, input); } diff --git a/frontend/src/hooks/useTransactions.ts b/frontend/src/hooks/useTransactions.ts index 1600084..6451574 100644 --- a/frontend/src/hooks/useTransactions.ts +++ b/frontend/src/hooks/useTransactions.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + bulkCreateTransactions, createTransaction, deleteTransaction, fetchTransactions, @@ -56,6 +57,14 @@ export function useUpdateTransaction() { }); } +export function useBulkCreateTransactions() { + const invalidate = useInvalidateAfterTransactionChange(); + return useMutation({ + mutationFn: (items: TransactionInput[]) => bulkCreateTransactions(items), + onSuccess: invalidate, + }); +} + export function useDeleteTransaction() { const invalidate = useInvalidateAfterTransactionChange(); return useMutation({ diff --git a/frontend/src/lib/csv.ts b/frontend/src/lib/csv.ts new file mode 100644 index 0000000..d039a0d --- /dev/null +++ b/frontend/src/lib/csv.ts @@ -0,0 +1,106 @@ +// Minimal RFC4180-ish CSV parser — handles quoted fields (with embedded +// delimiters/newlines, "" as an escaped quote) and auto-detects comma vs +// semicolon as the delimiter (many EU bank exports use ";"). No dependency +// pulled in for this; bank CSV exports don't need more than this covers. +export function parseCsv(text: string): string[][] { + const firstLine = text.slice(0, text.search(/\r?\n/) === -1 ? text.length : text.search(/\r?\n/)); + const commaCount = (firstLine.match(/,/g) ?? []).length; + const semicolonCount = (firstLine.match(/;/g) ?? []).length; + const delimiter = semicolonCount > commaCount ? ";" : ","; + + const rows: string[][] = []; + let row: string[] = []; + let field = ""; + let inQuotes = false; + + for (let i = 0; i < text.length; i++) { + const char = text[i]; + + if (inQuotes) { + if (char === '"') { + if (text[i + 1] === '"') { + field += '"'; + i += 1; + } else { + inQuotes = false; + } + } else { + field += char; + } + continue; + } + + if (char === '"') { + inQuotes = true; + } else if (char === delimiter) { + row.push(field); + field = ""; + } else if (char === "\r") { + // skip — \n (below) closes the row + } else if (char === "\n") { + row.push(field); + rows.push(row); + row = []; + field = ""; + } else { + field += char; + } + } + if (field.length > 0 || row.length > 0) { + row.push(field); + rows.push(row); + } + + return rows.filter((cells) => !(cells.length === 1 && cells[0].trim() === "")); +} + +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]; + +const DATE_PATTERNS: Record = { + "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"] }, +}; + +/** Parses `raw` per `format`, returning an ISO "YYYY-MM-DD" string, or null + * if it doesn't match the pattern or isn't a real calendar date (e.g. Feb 30 + * round-trips to Mar 2, which this catches by re-checking the parts). */ +export function parseDateWithFormat(raw: string, format: DateFormat): string | null { + const { regex, order } = DATE_PATTERNS[format]; + const match = raw.trim().match(regex); + if (!match) return null; + + const parts: Record<"y" | "m" | "d", number> = { y: 0, m: 0, d: 0 }; + order.forEach((key, index) => { + parts[key] = Number(match[index + 1]); + }); + const { y: year, m: month, d: day } = parts; + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + + const date = new Date(year, month - 1, day); + if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null; + + return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +/** Parses a bank-export amount string — handles thousands separators and + * either "," or "." as the decimal mark by assuming whichever comes last is + * the decimal separator (e.g. "1.234,56" and "1,234.56" both work). */ +export function parseAmount(raw: string): number | null { + let value = raw.trim().replace(/[\s ]/g, ""); + if (!value) return null; + + const hasComma = value.includes(","); + const hasDot = value.includes("."); + if (hasComma && hasDot) { + value = value.lastIndexOf(",") > value.lastIndexOf(".") ? value.replace(/\./g, "").replace(",", ".") : value.replace(/,/g, ""); + } else if (hasComma) { + value = value.replace(",", "."); + } + + const num = Number(value); + return Number.isFinite(num) ? num : null; +} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 7557ffd..a8c2e10 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -133,6 +133,37 @@ const ru = { "netWorth.form.saveError": "Не удалось сохранить актив. Проверьте данные и попробуйте снова.", "transactions.addButton": "Добавить", + "transactions.importButton": "Импорт CSV", + "transactions.import.title": "Импорт транзакций из CSV", + "transactions.import.accountLabel": "Счёт для импорта", + "transactions.import.accountHint": "Все транзакции из файла будут добавлены на этот счёт.", + "transactions.import.uploadTitle": "Файл", + "transactions.import.uploadHint": "Выгрузка транзакций из банка или другого приложения в формате CSV (с заголовками колонок в первой строке).", + "transactions.import.chooseFile": "Выбрать файл", + "transactions.import.pickAccountFirst": "Сначала выберите счёт.", + "transactions.import.errorEmptyFile": "В файле нет ни одной строки с данными (кроме заголовка).", + "transactions.import.mapTitle": "Сопоставьте колонки", + "transactions.import.dateColumnLabel": "Колонка с датой", + "transactions.import.dateFormatLabel": "Формат даты", + "transactions.import.selectColumn": "Выберите колонку…", + "transactions.import.amountColumnLabel": "Колонка с суммой", + "transactions.import.amountHint": "Отрицательные суммы — расход, положительные — доход.", + "transactions.import.descriptionColumnLabel": "Колонка с описанием", + "transactions.import.notMapped": "— не сопоставлено —", + "transactions.import.categoryHint": "Если текст в колонке точно совпадёт с названием существующей категории — она подставится автоматически.", + "transactions.import.startOver": "Начать заново", + "transactions.import.previewButton": "Показать превью", + "transactions.import.previewTitle": "Превью импорта", + "transactions.import.summary": "Готово к импорту: {{valid}}. Пропущено из-за ошибок: {{skipped}}.", + "transactions.import.andMore": "…и ещё {{count}}", + "transactions.import.skippedRow": "Строка {{row}}: {{reason}}", + "transactions.import.importButton": "Импортировать {{count}}", + "transactions.import.importSuccess": "Готово — добавлено транзакций: {{count}}.", + "transactions.import.goToTransactions": "К транзакциям", + "transactions.import.importError": "Не удалось импортировать. Проверьте данные и попробуйте снова.", + "transactions.import.errorBadDate": "не удалось распознать дату «{{value}}»", + "transactions.import.errorBadAmount": "не удалось распознать сумму «{{value}}»", + "transactions.import.errorNoDescription": "нет описания", "transactions.allTypes": "Все типы", "transactions.expense": "Расходы", "transactions.income": "Доходы", @@ -501,6 +532,37 @@ const en: Record = { "netWorth.form.saveError": "Failed to save the asset. Check the data and try again.", "transactions.addButton": "Add", + "transactions.importButton": "Import CSV", + "transactions.import.title": "Import transactions from CSV", + "transactions.import.accountLabel": "Account to import into", + "transactions.import.accountHint": "Every transaction from the file will be added to this account.", + "transactions.import.uploadTitle": "File", + "transactions.import.uploadHint": "A transaction export from your bank or another app, in CSV format (with column headers in the first row).", + "transactions.import.chooseFile": "Choose file", + "transactions.import.pickAccountFirst": "Pick an account first.", + "transactions.import.errorEmptyFile": "The file has no data rows (besides the header).", + "transactions.import.mapTitle": "Map the columns", + "transactions.import.dateColumnLabel": "Date column", + "transactions.import.dateFormatLabel": "Date format", + "transactions.import.selectColumn": "Select a column…", + "transactions.import.amountColumnLabel": "Amount column", + "transactions.import.amountHint": "Negative amounts are expenses, positive are income.", + "transactions.import.descriptionColumnLabel": "Description column", + "transactions.import.notMapped": "— not mapped —", + "transactions.import.categoryHint": "If the column's text exactly matches an existing category name, it's filled in automatically.", + "transactions.import.startOver": "Start over", + "transactions.import.previewButton": "Show preview", + "transactions.import.previewTitle": "Import preview", + "transactions.import.summary": "Ready to import: {{valid}}. Skipped due to errors: {{skipped}}.", + "transactions.import.andMore": "…and {{count}} more", + "transactions.import.skippedRow": "Row {{row}}: {{reason}}", + "transactions.import.importButton": "Import {{count}}", + "transactions.import.importSuccess": "Done — {{count}} transactions added.", + "transactions.import.goToTransactions": "Go to transactions", + "transactions.import.importError": "Import failed. Check the data and try again.", + "transactions.import.errorBadDate": "couldn't parse the date \"{{value}}\"", + "transactions.import.errorBadAmount": "couldn't parse the amount \"{{value}}\"", + "transactions.import.errorNoDescription": "no description", "transactions.allTypes": "All types", "transactions.expense": "Expenses", "transactions.income": "Income", diff --git a/frontend/src/pages/CsvImportPage.tsx b/frontend/src/pages/CsvImportPage.tsx new file mode 100644 index 0000000..fcf96ee --- /dev/null +++ b/frontend/src/pages/CsvImportPage.tsx @@ -0,0 +1,426 @@ +import { 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 } from "@/hooks/useTransactions"; +import { translateCategoryName } from "@/lib/categoryLabels"; +import { DATE_FORMATS, parseAmount, parseCsv, parseDateWithFormat, 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 = ""; + +interface Mapping { + date: string; + amount: string; + description: string; + merchant: string; + notes: string; + category: string; +} + +interface SkippedRow { + row: number; + reason: string; +} + +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 [headers, setHeaders] = useState([]); + const [dataRows, setDataRows] = useState([]); + const [mapping, setMapping] = useState({ date: "", amount: "", description: "", merchant: "", notes: "", category: "" }); + const [dateFormat, setDateFormat] = useState("YYYY-MM-DD"); + const [parseError, setParseError] = useState(null); + const [importError, setImportError] = useState(null); + const [createdCount, setCreatedCount] = useState(null); + + 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"; + } + + const { valid, skipped } = useMemo(() => { + if (step !== "preview") return { valid: [] as TransactionInput[], skipped: [] as SkippedRow[] }; + + 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 validRows: 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() : ""; + + const isoDate = parseDateWithFormat(rawDate, dateFormat); + if (!isoDate) { + skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorBadDate", { value: rawDate || "—" }) }); + return; + } + const amount = parseAmount(rawAmount); + 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; + + validRows.push({ + 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, + }); + }); + + return { valid: validRows, skipped: skippedRows }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [step, headers, dataRows, mapping, dateFormat, categoryLookup, accountId, t]); + + function handleFileSelected(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + setParseError(null); + + const reader = new FileReader(); + reader.onload = () => { + const text = String(reader.result ?? ""); + const rows = parseCsv(text); + if (rows.length < 2) { + setParseError(t("transactions.import.errorEmptyFile")); + return; + } + const [headerRow, ...rest] = rows; + setFileName(file.name); + setHeaders(headerRow); + setDataRows(rest); + // 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", "категория"), + }); + setStep("map"); + }; + reader.readAsText(file); + } + + function startOver() { + setStep("upload"); + setFileName(""); + setHeaders([]); + setDataRows([]); + 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); + + return ( +
+
+ + + +

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

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

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

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

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

+ + + {!accountId &&

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

} + {parseError &&

{parseError}

} +
+
+ )} + + {step === "map" && ( + + + {t("transactions.import.mapTitle")} + {fileName} + + +
+
+ + +
+
+ + +
+
+ + +

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

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +

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

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

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

+ +
+ ) : ( + <> +

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

+ + {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}

} + +
+ + +
+ + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/TransactionsPage.tsx b/frontend/src/pages/TransactionsPage.tsx index c56f21c..1c2d4ae 100644 --- a/frontend/src/pages/TransactionsPage.tsx +++ b/frontend/src/pages/TransactionsPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { useSearchParams } from "react-router-dom"; -import { Plus, Search, X } from "lucide-react"; +import { Link, useSearchParams } from "react-router-dom"; +import { FileUp, Plus, Search, X } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Button } from "@/components/ui/Button"; import { Input, Select } from "@/components/ui/Input"; @@ -137,10 +137,18 @@ export function TransactionsPage() { /> - +
+ + + + +