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

View File

@@ -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

View File

@@ -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(

View File

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

View File

@@ -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() {
<Route path="/categories" element={<CategoriesPage />} />
<Route path="/cash-flow" element={<CashFlowPage />} />
<Route path="/transactions" element={<TransactionsPage />} />
<Route path="/transactions/import" element={<CsvImportPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/budget" element={<BudgetPage />} />
<Route path="/advice" element={<AdvicePage />} />

View File

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

View File

@@ -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({

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": "Не удалось сохранить актив. Проверьте данные и попробуйте снова.",
"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<keyof typeof ru, string> = {
"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",

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 { 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() {
/>
</div>
</div>
<Button onClick={openCreateModal} className="w-full sm:w-auto">
<Plus size={16} />
{t("transactions.addButton")}
</Button>
<div className="flex gap-2">
<Link to="/transactions/import" className="flex-1 sm:flex-none">
<Button variant="secondary" className="w-full sm:w-auto">
<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 className="relative">