feat: import transactions from a CSV bank statement

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 <noreply@anthropic.com>
This commit is contained in:
ZProger
2026-08-25 02:44:30 +03:00
parent 94782ee073
commit 19f5cfa429
11 changed files with 706 additions and 8 deletions

View File

@@ -11,7 +11,14 @@ from app.models.category import Category
from app.models.enums import CategoryKind, TransactionType from app.models.enums import CategoryKind, TransactionType
from app.models.tag import Tag from app.models.tag import Tag
from app.models.transaction import Transaction 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"]) router = APIRouter(prefix="/transactions", tags=["transactions"])
@@ -154,6 +161,27 @@ async def create_transaction(payload: TransactionCreate, session: AsyncSession =
return refreshed.scalar_one() 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) @router.patch("/{transaction_id}", response_model=TransactionRead)
async def update_transaction( async def update_transaction(
transaction_id: int, payload: TransactionUpdate, session: AsyncSession = Depends(get_session) transaction_id: int, payload: TransactionUpdate, session: AsyncSession = Depends(get_session)

View File

@@ -78,3 +78,16 @@ class TransactionPage(BaseModel):
total: int total: int
page: int page: int
page_size: 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

View File

@@ -16,6 +16,44 @@ from httpx import AsyncClient
from tests.helpers import money, txn_payload as _txn 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): async def test_create_expense_rejects_income_category(client: AsyncClient, account_id, categories):
income_category = categories["Salary"] income_category = categories["Salary"]
resp = await client.post( resp = await client.post(

View File

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

View File

@@ -10,6 +10,7 @@ import { AdvicePage } from "@/pages/AdvicePage";
import { BudgetPage } from "@/pages/BudgetPage"; import { BudgetPage } from "@/pages/BudgetPage";
import { CashFlowPage } from "@/pages/CashFlowPage"; import { CashFlowPage } from "@/pages/CashFlowPage";
import { CategoriesPage } from "@/pages/CategoriesPage"; import { CategoriesPage } from "@/pages/CategoriesPage";
import { CsvImportPage } from "@/pages/CsvImportPage";
import { DashboardPage } from "@/pages/DashboardPage"; import { DashboardPage } from "@/pages/DashboardPage";
import { GoalsPage } from "@/pages/GoalsPage"; import { GoalsPage } from "@/pages/GoalsPage";
import { NetWorthPage } from "@/pages/NetWorthPage"; import { NetWorthPage } from "@/pages/NetWorthPage";
@@ -50,6 +51,7 @@ export default function App() {
<Route path="/categories" element={<CategoriesPage />} /> <Route path="/categories" element={<CategoriesPage />} />
<Route path="/cash-flow" element={<CashFlowPage />} /> <Route path="/cash-flow" element={<CashFlowPage />} />
<Route path="/transactions" element={<TransactionsPage />} /> <Route path="/transactions" element={<TransactionsPage />} />
<Route path="/transactions/import" element={<CsvImportPage />} />
<Route path="/reports" element={<ReportsPage />} /> <Route path="/reports" element={<ReportsPage />} />
<Route path="/budget" element={<BudgetPage />} /> <Route path="/budget" element={<BudgetPage />} />
<Route path="/advice" element={<AdvicePage />} /> <Route path="/advice" element={<AdvicePage />} />

View File

@@ -37,6 +37,12 @@ export function createTransaction(input: TransactionInput) {
return api.post<Transaction>("/transactions", input); return api.post<Transaction>("/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<TransactionInput>) { export function updateTransaction(id: number, input: Partial<TransactionInput>) {
return api.patch<Transaction>(`/transactions/${id}`, input); return api.patch<Transaction>(`/transactions/${id}`, input);
} }

View File

@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
bulkCreateTransactions,
createTransaction, createTransaction,
deleteTransaction, deleteTransaction,
fetchTransactions, 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() { export function useDeleteTransaction() {
const invalidate = useInvalidateAfterTransactionChange(); const invalidate = useInvalidateAfterTransactionChange();
return useMutation({ return useMutation({

106
frontend/src/lib/csv.ts Normal file
View File

@@ -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<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"] },
};
/** 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;
}

View File

@@ -133,6 +133,37 @@ const ru = {
"netWorth.form.saveError": "Не удалось сохранить актив. Проверьте данные и попробуйте снова.", "netWorth.form.saveError": "Не удалось сохранить актив. Проверьте данные и попробуйте снова.",
"transactions.addButton": "Добавить", "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.allTypes": "Все типы",
"transactions.expense": "Расходы", "transactions.expense": "Расходы",
"transactions.income": "Доходы", "transactions.income": "Доходы",
@@ -501,6 +532,37 @@ const en: Record<keyof typeof ru, string> = {
"netWorth.form.saveError": "Failed to save the asset. Check the data and try again.", "netWorth.form.saveError": "Failed to save the asset. Check the data and try again.",
"transactions.addButton": "Add", "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.allTypes": "All types",
"transactions.expense": "Expenses", "transactions.expense": "Expenses",
"transactions.income": "Income", "transactions.income": "Income",

View File

@@ -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<HTMLInputElement>(null);
const [accountId, setAccountId] = useState("");
const [step, setStep] = useState<Step>("upload");
const [fileName, setFileName] = useState("");
const [headers, setHeaders] = useState<string[]>([]);
const [dataRows, setDataRows] = useState<string[][]>([]);
const [mapping, setMapping] = useState<Mapping>({ date: "", amount: "", description: "", merchant: "", notes: "", category: "" });
const [dateFormat, setDateFormat] = useState<DateFormat>("YYYY-MM-DD");
const [parseError, setParseError] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(null);
const [createdCount, setCreatedCount] = useState<number | null>(null);
const categoryLookup = useMemo(() => {
const map: Record<"income" | "expense", Map<string, number>> = { 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<HTMLInputElement>) {
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 (
<div className="space-y-5">
<div className="flex items-center gap-2">
<Link
to="/transactions"
aria-label={t("common.back")}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-lg font-semibold text-text-primary">{t("transactions.import.title")}</h1>
</div>
<Card>
<CardHeader>
<CardTitle>{t("transactions.import.accountLabel")}</CardTitle>
</CardHeader>
<CardContent>
<Select value={accountId} onChange={(event) => setAccountId(event.target.value)} className="sm:w-72">
<option value="" disabled>
{t("transactions.form.selectAccount")}
</option>
{accounts?.map((account) => (
<option key={account.id} value={account.id}>
{account.name}
</option>
))}
</Select>
<p className="mt-2 text-xs text-text-muted">{t("transactions.import.accountHint")}</p>
</CardContent>
</Card>
{step === "upload" && (
<Card>
<CardHeader>
<CardTitle>{t("transactions.import.uploadTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-text-secondary">{t("transactions.import.uploadHint")}</p>
<Button onClick={() => fileInputRef.current?.click()} disabled={!accountId}>
<Upload size={16} />
{t("transactions.import.chooseFile")}
</Button>
<input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={handleFileSelected} className="hidden" />
{!accountId && <p className="text-xs text-text-muted">{t("transactions.import.pickAccountFirst")}</p>}
{parseError && <p className="text-sm text-danger">{parseError}</p>}
</CardContent>
</Card>
)}
{step === "map" && (
<Card>
<CardHeader>
<CardTitle>{t("transactions.import.mapTitle")}</CardTitle>
<span className="text-xs text-text-muted">{fileName}</span>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<Label htmlFor="map-date">{t("transactions.import.dateColumnLabel")}</Label>
<Select id="map-date" value={mapping.date} onChange={(event) => setMapping((prev) => ({ ...prev, date: event.target.value }))}>
<option value={NONE} disabled>
{t("transactions.import.selectColumn")}
</option>
{headers.map((header) => (
<option key={header} value={header}>
{header}
</option>
))}
</Select>
</div>
<div>
<Label htmlFor="map-date-format">{t("transactions.import.dateFormatLabel")}</Label>
<Select id="map-date-format" value={dateFormat} onChange={(event) => setDateFormat(event.target.value as DateFormat)}>
{DATE_FORMATS.map((format) => (
<option key={format} value={format}>
{format}
</option>
))}
</Select>
</div>
<div>
<Label htmlFor="map-amount">{t("transactions.import.amountColumnLabel")}</Label>
<Select id="map-amount" value={mapping.amount} onChange={(event) => setMapping((prev) => ({ ...prev, amount: event.target.value }))}>
<option value={NONE} disabled>
{t("transactions.import.selectColumn")}
</option>
{headers.map((header) => (
<option key={header} value={header}>
{header}
</option>
))}
</Select>
<p className="mt-1 text-xs text-text-muted">{t("transactions.import.amountHint")}</p>
</div>
<div>
<Label htmlFor="map-description">{t("transactions.import.descriptionColumnLabel")}</Label>
<Select
id="map-description"
value={mapping.description}
onChange={(event) => setMapping((prev) => ({ ...prev, description: event.target.value }))}
>
<option value={NONE} disabled>
{t("transactions.import.selectColumn")}
</option>
{headers.map((header) => (
<option key={header} value={header}>
{header}
</option>
))}
</Select>
</div>
<div>
<Label htmlFor="map-merchant">{t("transactions.form.merchantLabel")}</Label>
<Select id="map-merchant" value={mapping.merchant} onChange={(event) => setMapping((prev) => ({ ...prev, merchant: event.target.value }))}>
<option value={NONE}>{t("transactions.import.notMapped")}</option>
{headers.map((header) => (
<option key={header} value={header}>
{header}
</option>
))}
</Select>
</div>
<div>
<Label htmlFor="map-notes">{t("transactions.form.notesLabel")}</Label>
<Select id="map-notes" value={mapping.notes} onChange={(event) => setMapping((prev) => ({ ...prev, notes: event.target.value }))}>
<option value={NONE}>{t("transactions.import.notMapped")}</option>
{headers.map((header) => (
<option key={header} value={header}>
{header}
</option>
))}
</Select>
</div>
<div>
<Label htmlFor="map-category">{t("transactions.form.categoryLabel")}</Label>
<Select id="map-category" value={mapping.category} onChange={(event) => setMapping((prev) => ({ ...prev, category: event.target.value }))}>
<option value={NONE}>{t("transactions.import.notMapped")}</option>
{headers.map((header) => (
<option key={header} value={header}>
{header}
</option>
))}
</Select>
<p className="mt-1 text-xs text-text-muted">{t("transactions.import.categoryHint")}</p>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={startOver}>
{t("transactions.import.startOver")}
</Button>
<Button disabled={!mappingComplete} onClick={() => setStep("preview")}>
{t("transactions.import.previewButton")}
</Button>
</div>
</CardContent>
</Card>
)}
{step === "preview" && (
<Card>
<CardHeader>
<CardTitle>{t("transactions.import.previewTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{createdCount !== null ? (
<div className="space-y-3">
<p className="text-sm text-success">{t("transactions.import.importSuccess", { count: createdCount })}</p>
<Button onClick={() => navigate("/transactions")}>{t("transactions.import.goToTransactions")}</Button>
</div>
) : (
<>
<p className="text-sm text-text-secondary">
{t("transactions.import.summary", { valid: valid.length, skipped: skipped.length })}
</p>
{valid.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-left text-sm">
<thead className="bg-surface-2 text-xs text-text-muted">
<tr>
<th className="px-3 py-2 font-medium">{t("transactions.form.dateLabel")}</th>
<th className="px-3 py-2 font-medium">{t("transactions.form.descriptionLabel")}</th>
<th className="px-3 py-2 font-medium">{t("transactions.form.categoryLabel")}</th>
<th className="px-3 py-2 text-right font-medium">{t("transactions.form.amountLabel")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gridline">
{valid.slice(0, 20).map((item, index) => (
<tr key={index}>
<td className="whitespace-nowrap px-3 py-1.5 text-text-secondary">{item.date}</td>
<td className="px-3 py-1.5 text-text-primary">{item.description}</td>
<td className="whitespace-nowrap px-3 py-1.5 text-text-secondary">
{categories?.find((c) => c.id === item.category_id)
? translateCategoryName(categories.find((c) => c.id === item.category_id)!.name)
: "—"}
</td>
<td
className={`whitespace-nowrap px-3 py-1.5 text-right tabular-nums ${
item.type === "expense" ? "text-text-primary" : "text-success"
}`}
>
{item.type === "expense" ? "-" : "+"}
{formatCurrency(item.amount)}
</td>
</tr>
))}
</tbody>
</table>
{valid.length > 20 && (
<p className="border-t border-border px-3 py-2 text-xs text-text-muted">
{t("transactions.import.andMore", { count: valid.length - 20 })}
</p>
)}
</div>
)}
{skipped.length > 0 && (
<div className="max-h-40 overflow-y-auto rounded-lg border border-border bg-surface-2 p-3 text-xs text-text-muted">
{skipped.map((row) => (
<p key={row.row}>{t("transactions.import.skippedRow", { row: row.row, reason: row.reason })}</p>
))}
</div>
)}
{importError && <p className="text-sm text-danger">{importError}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setStep("map")}>
{t("common.back")}
</Button>
<Button disabled={valid.length === 0 || bulkCreate.isPending} onClick={() => void handleImport()}>
{bulkCreate.isPending
? t("common.saving")
: t("transactions.import.importButton", { count: valid.length })}
</Button>
</div>
</>
)}
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { Plus, Search, X } from "lucide-react"; import { FileUp, Plus, Search, X } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input, Select } from "@/components/ui/Input"; import { Input, Select } from "@/components/ui/Input";
@@ -137,10 +137,18 @@ export function TransactionsPage() {
/> />
</div> </div>
</div> </div>
<Button onClick={openCreateModal} className="w-full sm:w-auto"> <div className="flex gap-2">
<Plus size={16} /> <Link to="/transactions/import" className="flex-1 sm:flex-none">
{t("transactions.addButton")} <Button variant="secondary" className="w-full sm:w-auto">
</Button> <FileUp size={16} />
{t("transactions.importButton")}
</Button>
</Link>
<Button onClick={openCreateModal} className="flex-1 sm:w-auto">
<Plus size={16} />
{t("transactions.addButton")}
</Button>
</div>
</div> </div>
<div className="relative"> <div className="relative">