mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
106
frontend/src/lib/csv.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
426
frontend/src/pages/CsvImportPage.tsx
Normal file
426
frontend/src/pages/CsvImportPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user