28 Commits

Author SHA1 Message Date
Anton
ec62a0591e feat(frontend): refresh UI with warm fintech redesign 2026-04-22 11:40:15 +03:00
97b61de092 Merge pull request 'fix: close modal popups only on overlay mousedown' (#18) from fix/modal-mousedown-overlay-close into main
Reviewed-on: #18
2026-04-15 14:02:52 +00:00
Anton
0e0186fdbb fix: close modal popups only on overlay mousedown 2026-04-15 17:02:15 +03:00
02ca34d088 Merge pull request 'feat(analytics): account commission and investment transfers' (#17) from feat/commission-analytics-investments into main
Reviewed-on: #17
2026-04-15 11:32:56 +00:00
Anton
f6d3196254 merge main into feat/commission-analytics-investments
Made-with: Cursor
2026-04-15 14:30:29 +03:00
Anton
fccde4259d feat(analytics): account commission and investment transfers
Handle cashback commission imports, include commissions in analytics with separate investment metrics, and expose commission/version details in the UI.

Made-with: Cursor
2026-04-14 16:15:05 +03:00
e97203c4ab Merge pull request 'chore: adds versions and copyright' (#16) from feat/sidebar-versions-copyright into main
Reviewed-on: #16
2026-03-17 03:44:11 +00:00
vakabunga
6dd8c01f5e chore: adds versions and copyright 2026-03-17 06:43:26 +03:00
495c1e89bb Merge pull request 'refactor: center main content layout' (#15) from feat/center-layout into main
Reviewed-on: #15
2026-03-16 14:56:05 +00:00
Anton
032a9f4e3b feat: center main content layout 2026-03-16 17:54:35 +03:00
d5f49fd86f Merge pull request 'feat(ui): simplify clear history confirmation modal: confirmation now relies on two checkboxes only.' (#14) from feat/simplify-clear-modal into main
Reviewed-on: #14
2026-03-16 14:52:22 +00:00
Anton
ab88a0553d feat(ui): simplify clear history confirmation modal: confirmation now relies on two checkboxes only. 2026-03-16 17:51:47 +03:00
0589da5005 Merge pull request 'feat(imports): import history and delete by import' (#13) from feat/import-history into main
Reviewed-on: #13
2026-03-16 14:48:47 +00:00
c50e48d564 Merge pull request 'chore(frontend): adds favicon' (#12) from feat/add-favicon into main
Reviewed-on: #12
2026-03-16 14:48:37 +00:00
Anton
01b1f26553 feat(imports): import history and delete by import
Track imports in DB, show history in Data section, allow deleting
transactions of a specific import instead of clearing all.
2026-03-16 17:46:15 +03:00
Anton
ba3105bbe5 chore(frontend): adds favicon 2026-03-16 17:44:04 +03:00
f32a21f87a Merge pull request 'Revert SSE streaming for PDF import, use synchronous flow' (#11) from revert/remove-sse-streaming into main
Reviewed-on: #11
2026-03-14 17:13:00 +00:00
vakabunga
8b57dd987e Revert SSE streaming for PDF import, use synchronous flow
SSE streaming added unnecessary complexity and latency due to
buffering issues across Node.js event loop, Nginx proxy, and
Docker layers. Reverted to a simple synchronous request/response
for PDF conversion. Kept extractLlmErrorMessage for user-friendly
LLM errors, lazy-loaded pdf-parse, and extended Nginx timeout.
2026-03-14 20:12:27 +03:00
ea234ea007 Merge pull request 'fix: yield to event loop after each SSE write to flush socket' (#10) from fix/sse-event-loop-flush into main
Reviewed-on: #10
2026-03-14 17:00:51 +00:00
vakabunga
db4d5e4d00 fix: yield to event loop after each SSE write to flush socket
The for-await loop over OpenAI stream chunks runs synchronously when
data is buffered, causing res.write() calls to queue without flushing.
Add setImmediate yield after each progress event so the event loop
reaches its I/O phase and pushes data to the network immediately.
2026-03-14 19:59:22 +03:00
358fcaeff5 Merge pull request 'fix: disable gzip and pad SSE events to prevent proxy buffering' (#9) from fix/sse-gzip-buffering into main
Reviewed-on: #9
2026-03-14 16:46:07 +00:00
vakabunga
67fed57118 fix: disable gzip and pad SSE events to prevent proxy buffering
Add gzip off to Nginx import location — the global gzip on was
buffering text/event-stream responses. Pad each SSE event to 4 KB
with comment lines to push past any remaining proxy buffer threshold.
2026-03-14 19:45:33 +03:00
45a6f3d374 Merge pull request 'fix: eliminate SSE buffering through Nginx proxy' (#8) from fix/sse-proxy-buffering into main
Reviewed-on: #8
2026-03-14 14:31:16 +00:00
vakabunga
aaf8cacf75 fix: eliminate SSE buffering through Nginx proxy
Add 2 KB padding comment after headers to push past proxy buffer
threshold, enable TCP_NODELAY on the socket, and remove erroneous
chunked_transfer_encoding off from Nginx that caused full response
buffering.
2026-03-14 17:30:52 +03:00
vakabunga
e28d0f46d0 fix: reopen result modal from progress bar, faster progress, handle LLM context error
- Move import modal visibility into ImportContext so the Layout
  progress pill can reopen the result dialog after the modal was closed.
- Raise LLM progress cap from 85% to 98% and drop the intermediate
  -import 88%- SSE event to eliminate the visual stall after LLM finishes.
- Detect LLM context-length errors (n_keep >= n_ctx) and surface a
  clear message instead of generic -Временная ошибка конвертации-.
2026-03-14 17:05:55 +03:00
22be09c101 Merge pull request 'fix: adaptive LLM progress estimation and emit 85% on stream end' (#6) from fix/adaptive-llm-progress into main
Reviewed-on: #6
2026-03-14 13:43:27 +00:00
vakabunga
78c4730196 fix: adaptive LLM progress estimation and emit 85% on stream end
Hardcoded EXPECTED_CHARS (15k) caused progress to stall at ~20-25% for
short statements. Now expected size is derived from input text length.
Also emit an explicit 85% event when the LLM stream finishes, and
throttle SSE events to 300ms to reduce browser overhead.
2026-03-14 16:41:12 +03:00
f2d0c91488 Merge pull request 'feat: stream PDF import progress via SSE with global progress bar' (#5) from feature/pdf-import-sse-streaming into main
Reviewed-on: #5
2026-03-14 13:18:57 +00:00
53 changed files with 2334 additions and 2162 deletions

1
.gitignore vendored
View File

@@ -23,3 +23,4 @@ history.xlsx
match_analysis.py
match_report.txt
statements/
.cursor/

View File

@@ -15,13 +15,15 @@ family_budget/
## Tech stack
| Layer | Choice | Rationale |
|---------- |------------------------|--------------------------------------------------------|
| Backend | Express + TypeScript | Simple, well-known, sufficient for a small local app |
| Frontend | React + Vite + TS | Fast dev experience, modern tooling |
| Database | PostgreSQL | Deployed on Synology NAS |
| Migrations | Knex | Lightweight, SQL-close, supports seeds |
| Shared | Pure TypeScript types | Zero-runtime, imported by both backend and frontend |
| Layer | Choice | Rationale |
| ---------- | --------------------- | ---------------------------------------------------- |
| Backend | Express + TypeScript | Simple, well-known, sufficient for a small local app |
| Frontend | React + Vite + TS | Fast dev experience, modern tooling |
| Database | PostgreSQL | Deployed on Synology NAS |
| Migrations | Knex | Lightweight, SQL-close, supports seeds |
| Shared | Pure TypeScript types | Zero-runtime, imported by both backend and frontend |
## Prerequisites
@@ -39,4 +41,4 @@ npm install
npm run build -w shared
```
See `backend/README.md` and `frontend/README.md` for per-package instructions.
See `backend/README.md` and `frontend/README.md` for per-package instructions.

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/backend",
"version": "0.1.0",
"version": "0.5.12",
"private": true,
"scripts": {
"dev": "tsx watch src/app.ts",

View File

@@ -1,3 +1,5 @@
import fs from 'fs';
import path from 'path';
import express from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors';
@@ -5,8 +7,13 @@ import { config } from './config';
import { runMigrations } from './db/migrate';
import { requireAuth } from './middleware/auth';
const pkg = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'),
) as { version: string };
import authRouter from './routes/auth';
import importRouter from './routes/import';
import importsRouter from './routes/imports';
import transactionsRouter from './routes/transactions';
import accountsRouter from './routes/accounts';
import categoriesRouter from './routes/categories';
@@ -23,9 +30,14 @@ app.use(cors({ origin: true, credentials: true }));
// Auth routes (login is public; me/logout apply auth internally)
app.use('/api/auth', authRouter);
app.get('/api/version', (_req, res) => {
res.json({ version: pkg.version });
});
// All remaining /api routes require authentication
app.use('/api', requireAuth);
app.use('/api/import', importRouter);
app.use('/api/imports', importsRouter);
app.use('/api/transactions', transactionsRouter);
app.use('/api/accounts', accountsRouter);
app.use('/api/categories', categoriesRouter);

View File

@@ -133,6 +133,24 @@ const migrations: { name: string; sql: string }[] = [
AND NOT EXISTS (SELECT 1 FROM category_rules LIMIT 1);
`,
},
{
name: '005_imports_table',
sql: `
CREATE TABLE IF NOT EXISTS imports (
id BIGSERIAL PRIMARY KEY,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
account_id BIGINT REFERENCES accounts(id),
bank TEXT NOT NULL,
account_number_masked TEXT NOT NULL,
imported_count INT NOT NULL,
duplicates_skipped INT NOT NULL,
total_in_file INT NOT NULL
);
ALTER TABLE transactions
ADD COLUMN IF NOT EXISTS import_id BIGINT REFERENCES imports(id);
`,
},
{
name: '004_seed_category_rules_extended',
sql: `
@@ -176,6 +194,24 @@ const migrations: { name: string; sql: string }[] = [
AND EXISTS (SELECT 1 FROM categories LIMIT 1);
`,
},
{
name: '005_imports_table',
sql: `
CREATE TABLE IF NOT EXISTS imports (
id BIGSERIAL PRIMARY KEY,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
account_id BIGINT REFERENCES accounts(id),
bank TEXT NOT NULL,
account_number_masked TEXT NOT NULL,
imported_count INT NOT NULL,
duplicates_skipped INT NOT NULL,
total_in_file INT NOT NULL
);
ALTER TABLE transactions
ADD COLUMN IF NOT EXISTS import_id BIGINT REFERENCES imports(id);
`,
},
];
export async function runMigrations(): Promise<void> {

View File

@@ -3,7 +3,7 @@ import multer from 'multer';
import { asyncHandler } from '../utils';
import { importStatement, isValidationError } from '../services/import';
import {
convertPdfToStatementStreaming,
convertPdfToStatement,
isPdfConversionError,
} from '../services/pdfToStatement';
@@ -28,10 +28,6 @@ function isJsonFile(file: { mimetype: string; originalname: string }): boolean {
);
}
function sseWrite(res: import('express').Response, data: Record<string, unknown>) {
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
const router = Router();
router.post(
@@ -55,73 +51,28 @@ router.post(
return;
}
if (isPdfFile(file)) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
try {
const converted = await convertPdfToStatementStreaming(
file.buffer,
(stage, progress, message) => {
sseWrite(res, { stage, progress, message });
},
);
if (isPdfConversionError(converted)) {
sseWrite(res, {
stage: 'error',
message: converted.message,
});
res.end();
return;
}
sseWrite(res, {
stage: 'import',
progress: 88,
message: 'Импорт в базу данных...',
});
const result = await importStatement(converted);
if (isValidationError(result)) {
sseWrite(res, {
stage: 'error',
message: (result as { message: string }).message,
});
res.end();
return;
}
sseWrite(res, {
stage: 'done',
progress: 100,
result,
});
} catch (err) {
console.error('SSE import error:', err);
sseWrite(res, {
stage: 'error',
message: 'Внутренняя ошибка сервера',
});
}
res.end();
return;
}
// JSON files — synchronous response as before
let body: unknown;
try {
body = JSON.parse(file.buffer.toString('utf-8'));
} catch {
res.status(400).json({
error: 'BAD_REQUEST',
message: 'Некорректный JSON-файл',
});
return;
if (isPdfFile(file)) {
const converted = await convertPdfToStatement(file.buffer);
if (isPdfConversionError(converted)) {
res.status(converted.status).json({
error: converted.error,
message: converted.message,
});
return;
}
body = converted;
} else {
try {
body = JSON.parse(file.buffer.toString('utf-8'));
} catch {
res.status(400).json({
error: 'BAD_REQUEST',
message: 'Некорректный JSON-файл',
});
return;
}
}
const result = await importStatement(body);

View File

@@ -0,0 +1,36 @@
import { Router } from 'express';
import { pool } from '../db/pool';
import { asyncHandler } from '../utils';
import * as importsService from '../services/imports';
const router = Router();
router.get(
'/',
asyncHandler(async (_req, res) => {
const imports = await importsService.getImports();
res.json(imports);
}),
);
router.delete(
'/:id',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (isNaN(id)) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid import id' });
return;
}
const { rows } = await pool.query('SELECT 1 FROM imports WHERE id = $1', [id]);
if (rows.length === 0) {
res.status(404).json({ error: 'NOT_FOUND', message: 'Import not found' });
return;
}
const result = await importsService.deleteImport(id);
res.json(result);
}),
);
export default router;

View File

@@ -47,26 +47,91 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
const where = 'WHERE ' + conditions.join(' AND ');
const totalsResult = await pool.query(
`SELECT
COALESCE(SUM(CASE WHEN t.direction = 'expense' THEN ABS(t.amount_signed) ELSE 0 END), 0)::bigint AS total_expense,
COALESCE(SUM(CASE WHEN t.direction = 'income' THEN t.amount_signed ELSE 0 END), 0)::bigint AS total_income
`WITH investment_ref AS (
SELECT (
SELECT id
FROM categories
WHERE name = 'Инвестиции' AND type = 'transfer'
ORDER BY id ASC
LIMIT 1
) AS investment_category_id
)
SELECT
COALESCE(SUM(
CASE
WHEN (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)
THEN ABS(t.amount_signed) + t.commission
ELSE 0
END
), 0)::bigint AS total_expense,
COALESCE(SUM(
CASE
WHEN t.direction = 'income'
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)
THEN t.amount_signed + t.commission
ELSE 0
END
), 0)::bigint AS total_income,
COALESCE(SUM(
CASE
WHEN t.amount_signed < 0
AND ir.investment_category_id IS NOT NULL
AND t.category_id = ir.investment_category_id
THEN ABS(t.amount_signed) + t.commission
ELSE 0
END
), 0)::bigint AS investment_outflow,
COALESCE(SUM(
CASE
WHEN t.amount_signed > 0
AND ir.investment_category_id IS NOT NULL
AND t.category_id = ir.investment_category_id
THEN t.amount_signed + t.commission
ELSE 0
END
), 0)::bigint AS investment_income_excluded
FROM transactions t
CROSS JOIN investment_ref ir
${where}`,
values,
);
const totalExpense = Number(totalsResult.rows[0].total_expense);
const totalIncome = Number(totalsResult.rows[0].total_income);
const investmentOutflow = Number(totalsResult.rows[0].investment_outflow);
const investmentIncomeExcluded = Number(totalsResult.rows[0].investment_income_excluded);
const topResult = await pool.query(
`SELECT
t.category_id,
c.name AS category_name,
SUM(ABS(t.amount_signed))::bigint AS amount
`WITH investment_ref AS (
SELECT (
SELECT id
FROM categories
WHERE name = 'Инвестиции' AND type = 'transfer'
ORDER BY id ASC
LIMIT 1
) AS investment_category_id
)
SELECT
COALESCE(t.category_id, 0)::bigint AS category_id,
COALESCE(c.name, 'Без категории') AS category_name,
SUM(ABS(t.amount_signed) + t.commission)::bigint AS amount
FROM transactions t
CROSS JOIN investment_ref ir
LEFT JOIN categories c ON c.id = t.category_id
${where} AND t.direction = 'expense' AND t.category_id IS NOT NULL
GROUP BY t.category_id, c.name
${where}
AND (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)
GROUP BY COALESCE(t.category_id, 0), COALESCE(c.name, 'Без категории')
ORDER BY amount DESC
LIMIT 5`,
values,
@@ -83,6 +148,8 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
totalExpense,
totalIncome,
net: totalIncome - totalExpense,
investmentOutflow,
investmentIncomeExcluded,
topCategories,
};
}
@@ -92,23 +159,53 @@ export async function getByCategory(params: BaseParams): Promise<ByCategoryItem[
const where = 'WHERE ' + conditions.join(' AND ');
const totalResult = await pool.query(
`SELECT COALESCE(SUM(ABS(t.amount_signed)), 0)::bigint AS total
`WITH investment_ref AS (
SELECT (
SELECT id
FROM categories
WHERE name = 'Инвестиции' AND type = 'transfer'
ORDER BY id ASC
LIMIT 1
) AS investment_category_id
)
SELECT COALESCE(SUM(ABS(t.amount_signed) + t.commission), 0)::bigint AS total
FROM transactions t
${where} AND t.direction = 'expense'`,
CROSS JOIN investment_ref ir
${where}
AND (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)`,
values,
);
const total = Number(totalResult.rows[0].total);
const { rows } = await pool.query(
`SELECT
t.category_id,
c.name AS category_name,
SUM(ABS(t.amount_signed))::bigint AS amount,
`WITH investment_ref AS (
SELECT (
SELECT id
FROM categories
WHERE name = 'Инвестиции' AND type = 'transfer'
ORDER BY id ASC
LIMIT 1
) AS investment_category_id
)
SELECT
COALESCE(t.category_id, 0)::bigint AS category_id,
COALESCE(c.name, 'Без категории') AS category_name,
SUM(ABS(t.amount_signed) + t.commission)::bigint AS amount,
COUNT(*)::int AS tx_count
FROM transactions t
CROSS JOIN investment_ref ir
LEFT JOIN categories c ON c.id = t.category_id
${where} AND t.direction = 'expense'
GROUP BY t.category_id, c.name
${where}
AND (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)
GROUP BY COALESCE(t.category_id, 0), COALESCE(c.name, 'Без категории')
ORDER BY amount DESC`,
values,
);
@@ -174,13 +271,52 @@ export async function getTimeseries(
gs::date AS period_start,
${periodEndExpr} AS period_end
FROM generate_series(${truncExpr}, $2::date, '${intervalStr}'::interval) gs
),
investment_ref AS (
SELECT (
SELECT id
FROM categories
WHERE name = 'Инвестиции' AND type = 'transfer'
ORDER BY id ASC
LIMIT 1
) AS investment_category_id
)
SELECT
p.period_start,
p.period_end,
COALESCE(SUM(CASE WHEN t.direction = 'expense' THEN ABS(t.amount_signed) ELSE 0 END), 0)::bigint AS expense_amount,
COALESCE(SUM(CASE WHEN t.direction = 'income' THEN t.amount_signed ELSE 0 END), 0)::bigint AS income_amount
COALESCE(SUM(
CASE
WHEN (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)
THEN ABS(t.amount_signed) + t.commission
ELSE 0
END
), 0)::bigint AS expense_amount,
COALESCE(SUM(
CASE
WHEN t.direction = 'income'
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)
THEN t.amount_signed + t.commission
ELSE 0
END
), 0)::bigint AS income_amount,
COALESCE(SUM(
CASE
WHEN t.amount_signed < 0
AND ir.investment_category_id IS NOT NULL
AND t.category_id = ir.investment_category_id
THEN ABS(t.amount_signed) + t.commission
ELSE 0
END
), 0)::bigint AS investment_outflow
FROM periods p
CROSS JOIN investment_ref ir
LEFT JOIN transactions t ON ${txWhere}
GROUP BY p.period_start, p.period_end
ORDER BY p.period_start`,
@@ -192,5 +328,6 @@ export async function getTimeseries(
periodEnd: r.period_end.toISOString().slice(0, 10),
expenseAmount: Number(r.expense_amount),
incomeAmount: Number(r.income_amount),
investmentOutflow: Number(r.investment_outflow),
}));
}

View File

@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid';
import { pool } from '../db/pool';
import { config } from '../config';
import type { LoginRequest, MeResponse } from '@family-budget/shared';
import backendPackage from '../../package.json';
export async function login(
body: LoginRequest,
@@ -26,5 +27,8 @@ export async function logout(sessionId: string): Promise<void> {
}
export async function me(sessionId: string): Promise<MeResponse> {
return { login: config.appUserLogin };
return {
login: config.appUserLogin,
backendVersion: backendPackage.version,
};
}

View File

@@ -8,6 +8,7 @@ const TRANSFER_PHRASES = [
'перевод средств на счет',
'внутри втб',
];
const CASHBACK_KEYWORD = 'зачисление';
function computeFingerprint(
accountNumber: string,
@@ -84,15 +85,25 @@ function validateStructure(body: unknown): ValidationError | null {
if (typeof t.operationAt !== 'string' || !ISO_WITH_OFFSET.test(t.operationAt)) {
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].operationAt must be ISO 8601 with offset` };
}
if (typeof t.amountSigned !== 'number' || !Number.isInteger(t.amountSigned) || t.amountSigned === 0) {
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].amountSigned must be a non-zero integer` };
}
if (typeof t.commission !== 'number' || !Number.isInteger(t.commission) || t.commission < 0) {
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].commission must be a non-negative integer` };
}
if (typeof t.description !== 'string' || !t.description) {
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].description must be a non-empty string` };
}
if (typeof t.amountSigned !== 'number' || !Number.isInteger(t.amountSigned)) {
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].amountSigned must be an integer` };
}
if (t.amountSigned === 0) {
const hasCashbackMarker = t.description.toLowerCase().includes(CASHBACK_KEYWORD);
if (t.commission <= 0 || !hasCashbackMarker) {
return {
status: 400,
error: 'BAD_REQUEST',
message: `transactions[${i}] with amountSigned=0 must have commission>0 and contain 'Зачисление' in description`,
};
}
}
}
return null;
@@ -155,20 +166,49 @@ export async function importStatement(
isNewAccount = true;
}
// Create import record (counts updated after loop)
const accountNumberMasked = maskAccountNumber(data.statement.accountNumber);
const importResult = await client.query(
`INSERT INTO imports (account_id, bank, account_number_masked, imported_count, duplicates_skipped, total_in_file)
VALUES ($1, $2, $3, 0, 0, $4)
RETURNING id`,
[accountId, data.bank, accountNumberMasked, data.transactions.length],
);
const importId = Number(importResult.rows[0].id);
const incomeCategoryResult = await client.query(
`SELECT id
FROM categories
WHERE name = 'Поступления' AND type = 'income' AND is_active = TRUE
ORDER BY id ASC
LIMIT 1`,
);
if (incomeCategoryResult.rows.length === 0) {
throw new Error("Category 'Поступления' is missing");
}
const incomeCategoryId = Number(incomeCategoryResult.rows[0].id);
// Insert transactions
const insertedIds: number[] = [];
for (const tx of data.transactions) {
const fp = computeFingerprint(data.statement.accountNumber, tx);
const dir = determineDirection(tx.amountSigned, tx.description);
const isCashbackCommissionImport =
tx.amountSigned === 0 &&
tx.commission > 0 &&
tx.description.toLowerCase().includes(CASHBACK_KEYWORD);
const dir = isCashbackCommissionImport
? 'income'
: determineDirection(tx.amountSigned, tx.description);
const categoryId = isCashbackCommissionImport ? incomeCategoryId : null;
const isCategoryConfirmed = isCashbackCommissionImport;
const result = await client.query(
`INSERT INTO transactions
(account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, FALSE)
(account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (account_id, fingerprint) DO NOTHING
RETURNING id`,
[accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp],
[accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, categoryId, isCategoryConfirmed, importId],
);
if (result.rows.length > 0) {
@@ -176,6 +216,13 @@ export async function importStatement(
}
}
// Update import record with actual counts
const duplicatesSkipped = data.transactions.length - insertedIds.length;
await client.query(
`UPDATE imports SET imported_count = $1, duplicates_skipped = $2 WHERE id = $3`,
[insertedIds.length, duplicatesSkipped, importId],
);
// Auto-categorize newly inserted transactions
if (insertedIds.length > 0) {
await client.query(
@@ -208,7 +255,7 @@ export async function importStatement(
return {
accountId,
isNewAccount,
accountNumberMasked: maskAccountNumber(data.statement.accountNumber),
accountNumberMasked,
imported: insertedIds.length,
duplicatesSkipped: data.transactions.length - insertedIds.length,
totalInFile: data.transactions.length,

View File

@@ -0,0 +1,44 @@
import { pool } from '../db/pool';
import type { Import } from '@family-budget/shared';
export async function getImports(): Promise<Import[]> {
const result = await pool.query(
`SELECT
i.id,
i.imported_at,
i.account_id,
a.alias AS account_alias,
i.bank,
i.account_number_masked,
i.imported_count,
i.duplicates_skipped,
i.total_in_file
FROM imports i
LEFT JOIN accounts a ON a.id = i.account_id
ORDER BY i.imported_at DESC`,
);
return result.rows.map((r) => ({
id: Number(r.id),
importedAt: r.imported_at.toISOString(),
accountId: r.account_id != null ? Number(r.account_id) : null,
accountAlias: r.account_alias ?? null,
bank: r.bank,
accountNumberMasked: r.account_number_masked,
importedCount: Number(r.imported_count),
duplicatesSkipped: Number(r.duplicates_skipped),
totalInFile: Number(r.total_in_file),
}));
}
export async function deleteImport(id: number): Promise<{ deleted: number }> {
const result = await pool.query(
'DELETE FROM transactions WHERE import_id = $1 RETURNING id',
[id],
);
const deleted = result.rowCount ?? 0;
await pool.query('DELETE FROM imports WHERE id = $1', [id]);
return { deleted };
}

View File

@@ -19,7 +19,7 @@ const PDF2JSON_PROMPT = `Ты — конвертер банковских вып
"transactions": [
{
"operationAt": "<дата и время операции в формате ISO 8601 с offset>",
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>,
"amountSigned": <число: положительное для прихода, отрицательное для расхода; 0 допустим только если сумма фактически в commission и в description есть «Зачисление»>,
"commission": <число, целое, >= 0, в копейках>,
"description": "<полное описание операции из выписки>"
}
@@ -30,6 +30,7 @@ const PDF2JSON_PROMPT = `Ты — конвертер банковских вып
1. Суммы — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456.
2. amountSigned: приход — положительное, расход — отрицательное.
amountSigned = 0 допускается только для кейса кэшбэка/зачисления, когда сумма указана в commission и в description есть «Зачисление».
3. operationAt — дата и время, если не указано — 00:00:00, offset +03:00 для МСК.
4. commission — если не указана — 0.
5. description — полный текст операции как в выписке.
@@ -131,135 +132,49 @@ export async function convertPdfToStatement(
};
}
return parseConversionResult(content);
const jsonMatch = content.match(/\{[\s\S]*\}/);
const jsonStr = jsonMatch ? jsonMatch[0] : content;
let parsed: unknown;
try {
parsed = JSON.parse(jsonStr);
} catch {
return {
status: 422,
error: 'VALIDATION_ERROR',
message: 'Результат конвертации не является валидным JSON',
};
}
const data = parsed as Record<string, unknown>;
if (data.schemaVersion !== '1.0') {
return {
status: 422,
error: 'VALIDATION_ERROR',
message: 'Результат конвертации не соответствует схеме 1.0',
};
}
return parsed as StatementFile;
} catch (err) {
console.error('LLM conversion error:', err);
return {
status: 502,
error: 'BAD_GATEWAY',
message: 'Временная ошибка конвертации',
message: extractLlmErrorMessage(err),
};
}
}
export type ProgressStage = 'pdf' | 'llm' | 'import';
export type OnProgress = (stage: ProgressStage, progress: number, message: string) => void;
const EXPECTED_CHARS = 15_000;
export async function convertPdfToStatementStreaming(
buffer: Buffer,
onProgress: OnProgress,
): Promise<StatementFile | PdfConversionError> {
if (!config.llmApiKey || config.llmApiKey.trim() === '') {
return {
status: 503,
error: 'SERVICE_UNAVAILABLE',
message: 'Конвертация PDF недоступна: не задан LLM_API_KEY',
};
function extractLlmErrorMessage(err: unknown): string {
const raw = String(
(err as Record<string, unknown>)?.message ??
(err as Record<string, Record<string, unknown>>)?.error?.message ?? '',
);
if (/context.length|n_ctx|too.many.tokens|maximum.context/i.test(raw)) {
return 'PDF-файл слишком большой для обработки. Попробуйте файл с меньшим количеством операций или используйте модель с большим контекстным окном.';
}
onProgress('pdf', 2, 'Извлечение текста из PDF...');
let text: string;
try {
const result = await getPdfParse()(buffer);
text = result.text || '';
} catch (err) {
console.error('PDF extraction error:', err);
return {
status: 400,
error: 'BAD_REQUEST',
message: 'Не удалось обработать PDF-файл',
};
}
if (!text || text.trim().length === 0) {
return {
status: 400,
error: 'BAD_REQUEST',
message: 'Не удалось извлечь текст из PDF',
};
}
onProgress('pdf', 8, 'Текст извлечён, отправка в LLM...');
const openai = new OpenAI({
apiKey: config.llmApiKey,
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
timeout: 5 * 60 * 1000,
});
try {
const stream = await openai.chat.completions.create({
model: config.llmModel,
messages: [
{ role: 'system', content: PDF2JSON_PROMPT },
{ role: 'user', content: `Текст выписки:\n\n${text}` },
],
temperature: 0,
max_tokens: 32768,
stream: true,
});
let accumulated = '';
let charsReceived = 0;
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) {
accumulated += delta;
charsReceived += delta.length;
const llmProgress = Math.min(
85,
Math.round((charsReceived / EXPECTED_CHARS) * 75 + 10),
);
onProgress('llm', llmProgress, 'Конвертация через LLM...');
}
}
const content = accumulated.trim();
if (!content) {
return {
status: 422,
error: 'VALIDATION_ERROR',
message: 'Результат конвертации пуст',
};
}
return parseConversionResult(content);
} catch (err) {
console.error('LLM streaming error:', err);
return {
status: 502,
error: 'BAD_GATEWAY',
message: 'Временная ошибка конвертации',
};
if (/timeout|timed?\s*out|ETIMEDOUT|ECONNREFUSED/i.test(raw)) {
return 'LLM-сервер не отвечает. Проверьте, что сервер запущен и доступен.';
}
}
function parseConversionResult(content: string): StatementFile | PdfConversionError {
const jsonMatch = content.match(/\{[\s\S]*\}/);
const jsonStr = jsonMatch ? jsonMatch[0] : content;
let parsed: unknown;
try {
parsed = JSON.parse(jsonStr);
} catch {
return {
status: 422,
error: 'VALIDATION_ERROR',
message: 'Результат конвертации не является валидным JSON',
};
}
const data = parsed as Record<string, unknown>;
if (data.schemaVersion !== '1.0') {
return {
status: 422,
error: 'VALIDATION_ERROR',
message: 'Результат конвертации не соответствует схеме 1.0',
};
}
return parsed as StatementFile;
return 'Временная ошибка конвертации';
}

View File

@@ -171,5 +171,6 @@ export async function updateTransaction(
export async function clearAllTransactions(): Promise<{ deleted: number }> {
const result = await pool.query('DELETE FROM transactions RETURNING id');
await pool.query('DELETE FROM imports');
return { deleted: result.rowCount ?? 0 };
}

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f172a" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Семейный бюджет</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@@ -3,7 +3,7 @@ server {
root /usr/share/nginx/html;
index index.html;
# Import endpoint — SSE streaming, long timeout, no buffering
# Import endpoint — long timeout for LLM processing
location /api/import {
proxy_pass http://family-budget-backend:3000;
proxy_http_version 1.1;
@@ -14,8 +14,6 @@ server {
proxy_cookie_path / /;
proxy_connect_timeout 5s;
proxy_read_timeout 600s;
proxy_buffering off;
chunked_transfer_encoding off;
client_max_body_size 15m;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/frontend",
"version": "0.1.0",
"version": "0.8.6",
"private": true,
"type": "module",
"scripts": {

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#0f172a"/>
<text x="16" y="23" font-family="Georgia, serif" font-size="20" font-weight="bold" fill="#3b82f6" text-anchor="middle">&#8381;</text>
</svg>

After

Width:  |  Height:  |  Size: 271 B

View File

@@ -1,6 +1,5 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './context/AuthContext';
import { ImportProvider } from './context/ImportContext';
import { Layout } from './components/Layout';
import { LoginPage } from './pages/LoginPage';
import { HistoryPage } from './pages/HistoryPage';
@@ -11,7 +10,7 @@ export function App() {
const { user, loading } = useAuth();
if (loading) {
return <div className="app-loading">Загрузка...</div>;
return <div className="app-state app-state--loading">Загрузка...</div>;
}
if (!user) {
@@ -19,16 +18,14 @@ export function App() {
}
return (
<ImportProvider>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/history" replace />} />
<Route path="/history" element={<HistoryPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/history" replace />} />
</Routes>
</Layout>
</ImportProvider>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/history" replace />} />
<Route path="/history" element={<HistoryPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/history" replace />} />
</Routes>
</Layout>
);
}

View File

@@ -1,4 +1,7 @@
import type { ImportStatementResponse } from '@family-budget/shared';
import type {
ImportStatementResponse,
Import,
} from '@family-budget/shared';
import { api } from './client';
export async function importStatement(
@@ -12,73 +15,12 @@ export async function importStatement(
);
}
export interface SseProgressEvent {
stage: 'pdf' | 'llm' | 'import';
progress: number;
message: string;
export async function getImports(): Promise<Import[]> {
return api.get<Import[]>('/api/imports');
}
export interface SseDoneEvent {
stage: 'done';
progress: 100;
result: ImportStatementResponse;
}
export interface SseErrorEvent {
stage: 'error';
message: string;
}
export type SseEvent = SseProgressEvent | SseDoneEvent | SseErrorEvent;
export async function importStatementStream(
file: File,
onEvent: (event: SseEvent) => void,
): Promise<void> {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/import/statement', {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!res.ok) {
let msg = 'Ошибка импорта';
try {
const body = await res.json();
if (body.message) msg = body.message;
} catch { /* use default */ }
onEvent({ stage: 'error', message: msg });
return;
}
const reader = res.body?.getReader();
if (!reader) {
onEvent({ stage: 'error', message: 'Streaming не поддерживается' });
return;
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data: ')) continue;
try {
const parsed = JSON.parse(trimmed.slice(6)) as SseEvent;
onEvent(parsed);
} catch { /* skip malformed lines */ }
}
}
export async function deleteImport(
id: number,
): Promise<{ deleted: number }> {
return api.delete<{ deleted: number }>(`/api/imports/${id}`);
}

View File

@@ -0,0 +1,5 @@
import { api } from './client';
export async function getBackendVersion(): Promise<{ version: string }> {
return api.get<{ version: string }>('/api/version');
}

View File

@@ -36,7 +36,7 @@ export function AccountsList() {
};
if (loading) {
return <div className="section-loading">Загрузка...</div>;
return <div className="state state--loading">Загрузка...</div>;
}
return (
@@ -72,21 +72,21 @@ export function AccountsList() {
/>
) : (
a.alias || (
<span className="text-muted">не задан</span>
<span className="muted-text">не задан</span>
)
)}
</td>
<td>
{editingId === a.id ? (
<div className="btn-group">
<div className="button-group">
<button
className="btn btn-sm btn-primary"
className="button button--primary button--small"
onClick={() => handleSave(a.id)}
>
Сохранить
</button>
<button
className="btn btn-sm btn-secondary"
className="button button--secondary button--small"
onClick={() => setEditingId(null)}
>
Отмена
@@ -94,7 +94,7 @@ export function AccountsList() {
</div>
) : (
<button
className="btn btn-sm btn-secondary"
className="button button--secondary button--small"
onClick={() => handleEdit(a)}
>
Изменить
@@ -105,7 +105,7 @@ export function AccountsList() {
))}
{accounts.length === 0 && (
<tr>
<td colSpan={5} className="td-center text-muted">
<td colSpan={5} className="data-table__cell data-table__cell--center muted-text">
Нет счетов. Импортируйте выписку.
</td>
</tr>

View File

@@ -21,7 +21,7 @@ export function CategoriesList() {
}, []);
if (loading) {
return <div className="section-loading">Загрузка...</div>;
return <div className="state state--loading">Загрузка...</div>;
}
return (
@@ -36,9 +36,9 @@ export function CategoriesList() {
<tbody>
{categories.map((c) => (
<tr key={c.id}>
<td>{c.name}</td>
<td>
<span className={`badge badge-${c.type}`}>
<td>{c.name}</td>
<td>
<span className={`badge badge--${c.type}`}>
{TYPE_LABELS[c.type] ?? c.type}
</span>
</td>

View File

@@ -14,9 +14,9 @@ interface Props {
}
const COLORS = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1',
'#14b8a6', '#e11d48', '#0ea5e9', '#a855f7', '#22c55e',
'#2563eb', '#e85d3f', '#0f9f7f', '#d89b17', '#7c5cdb',
'#c8558f', '#1495a3', '#79a92f', '#e06d2f', '#4f6fd7',
'#1c9a8a', '#d43d5c', '#277bbd', '#9b59b6', '#2f9f63',
];
const rubFormatter = new Intl.NumberFormat('ru-RU', {
@@ -30,7 +30,7 @@ export function CategoryChart({ data }: Props) {
const chartHeight = isMobile ? 250 : 300;
if (data.length === 0) {
return <div className="chart-empty">Нет данных за период</div>;
return <div className="state state--empty">Нет данных за период</div>;
}
const chartData = data.map((item) => ({
@@ -41,7 +41,7 @@ export function CategoryChart({ data }: Props) {
}));
return (
<div className="category-chart-wrapper">
<div className="category-chart">
<ResponsiveContainer width="100%" height={chartHeight}>
<PieChart>
<Pie
@@ -58,7 +58,7 @@ export function CategoryChart({ data }: Props) {
>
{chartData.map((_, idx) => (
<Cell
key={idx}
key={`${idx}-${COLORS[idx % COLORS.length]}`}
fill={COLORS[idx % COLORS.length]}
/>
))}
@@ -69,13 +69,13 @@ export function CategoryChart({ data }: Props) {
</PieChart>
</ResponsiveContainer>
<table className="category-table">
<table className="category-chart__table">
<thead>
<tr>
<th>Категория</th>
<th>Сумма</th>
<th className="th-center">Операций</th>
<th className="th-center">Доля</th>
<th className="category-chart__cell category-chart__cell--center">Операций</th>
<th className="category-chart__cell category-chart__cell--center">Доля</th>
</tr>
</thead>
<tbody>
@@ -83,7 +83,7 @@ export function CategoryChart({ data }: Props) {
<tr key={item.categoryId}>
<td>
<span
className="color-dot"
className="category-chart__dot"
style={{
backgroundColor:
COLORS[idx % COLORS.length],
@@ -92,8 +92,8 @@ export function CategoryChart({ data }: Props) {
{item.categoryName}
</td>
<td>{formatAmount(item.amount)}</td>
<td className="td-center">{item.txCount}</td>
<td className="td-center">
<td className="category-chart__cell category-chart__cell--center">{item.txCount}</td>
<td className="category-chart__cell category-chart__cell--center">
{(item.share * 100).toFixed(1)}%
</td>
</tr>

View File

@@ -1,8 +1,6 @@
import { useState } from 'react';
import { clearAllTransactions } from '../api/transactions';
const CONFIRM_WORD = 'УДАЛИТЬ';
interface Props {
onClose: () => void;
onDone: () => void;
@@ -10,15 +8,11 @@ interface Props {
export function ClearHistoryModal({ onClose, onDone }: Props) {
const [check1, setCheck1] = useState(false);
const [confirmInput, setConfirmInput] = useState('');
const [check2, setCheck2] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const canConfirm =
check1 &&
confirmInput.trim().toUpperCase() === CONFIRM_WORD &&
check2;
const canConfirm = check1 && check2;
const handleConfirm = async () => {
if (!canConfirm || loading) return;
@@ -37,24 +31,29 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
};
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-backdrop"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div className="modal__header">
<h2>Очистить историю операций</h2>
<button className="btn-close" onClick={onClose}>
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
&times;
</button>
</div>
<div className="modal-body">
<p className="clear-history-warn">
<div className="modal__body">
<p className="danger-note">
Все транзакции будут безвозвратно удалены. Счета и категории
сохранятся.
</p>
{error && <div className="alert alert-error">{error}</div>}
{error && <div className="alert alert--error">{error}</div>}
<div className="form-group form-group-checkbox clear-history-check">
<div className="field field--checkbox danger-note__check">
<label>
<input
type="checkbox"
@@ -65,21 +64,7 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
</label>
</div>
<div className="form-group">
<label>
Введите <strong>{CONFIRM_WORD}</strong> для подтверждения
</label>
<input
type="text"
value={confirmInput}
onChange={(e) => setConfirmInput(e.target.value)}
placeholder={CONFIRM_WORD}
className={confirmInput && confirmInput.trim().toUpperCase() !== CONFIRM_WORD ? 'input-error' : ''}
autoComplete="off"
/>
</div>
<div className="form-group form-group-checkbox clear-history-check">
<div className="field field--checkbox danger-note__check">
<label>
<input
type="checkbox"
@@ -92,15 +77,15 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
</div>
</div>
<div className="modal-footer">
<div className="modal__footer">
<button
className="btn btn-danger"
className="button button--danger"
onClick={handleConfirm}
disabled={!canConfirm || loading}
>
{loading ? 'Удаление…' : 'Удалить всё'}
</button>
<button className="btn btn-secondary" onClick={onClose}>
<button className="button button--secondary" onClick={onClose}>
Отмена
</button>
</div>

View File

@@ -1,22 +1,96 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ClearHistoryModal } from './ClearHistoryModal';
import { DeleteImportModal } from './DeleteImportModal';
import { getImports } from '../api/import';
import type { Import } from '@family-budget/shared';
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function DataSection() {
const [showClearModal, setShowClearModal] = useState(false);
const [imports, setImports] = useState<Import[]>([]);
const [impToDelete, setImpToDelete] = useState<Import | null>(null);
const navigate = useNavigate();
useEffect(() => {
getImports().then(setImports).catch(() => {});
}, []);
const handleImportDeleted = () => {
setImpToDelete(null);
getImports().then(setImports).catch(() => {});
navigate('/history');
};
return (
<div className="data-section">
<div className="section-block">
<div className="data-section__block">
<h3>История импортов</h3>
<p className="data-section__description">
Список импортов выписок. Можно удалить операции конкретного импорта.
</p>
{imports.length === 0 ? (
<p className="muted-text">Импортов пока нет.</p>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Дата</th>
<th>Счёт</th>
<th>Банк</th>
<th>Импортировано</th>
<th>Дубликаты</th>
<th></th>
</tr>
</thead>
<tbody>
{imports.map((imp) => (
<tr key={imp.id}>
<td>{formatDate(imp.importedAt)}</td>
<td>
{imp.accountAlias || imp.accountNumberMasked || '—'}
</td>
<td>{imp.bank}</td>
<td>{imp.importedCount}</td>
<td>{imp.duplicatesSkipped}</td>
<td>
<button
type="button"
className="button button--danger button--small"
onClick={() => setImpToDelete(imp)}
disabled={imp.importedCount === 0}
>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="data-section__block">
<h3>Очистка данных</h3>
<p className="section-desc">
<p className="data-section__description">
Очистить историю операций (все транзакции). Счета, категории и
правила сохранятся.
</p>
<button
type="button"
className="btn btn-danger"
className="button button--danger"
onClick={() => setShowClearModal(true)}
>
Очистить историю
@@ -32,6 +106,14 @@ export function DataSection() {
}}
/>
)}
{impToDelete && (
<DeleteImportModal
imp={impToDelete}
onClose={() => setImpToDelete(null)}
onDone={handleImportDeleted}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { useState } from 'react';
import { deleteImport } from '../api/import';
import type { Import } from '@family-budget/shared';
interface Props {
imp: Import;
onClose: () => void;
onDone: () => void;
}
export function DeleteImportModal({ imp, onClose, onDone }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const accountLabel =
imp.accountAlias || imp.accountNumberMasked || `ID ${imp.accountId}`;
const handleConfirm = async () => {
setLoading(true);
setError('');
try {
await deleteImport(imp.id);
onDone();
} catch (e) {
setError(
e instanceof Error ? e.message : 'Ошибка при удалении импорта',
);
} finally {
setLoading(false);
}
};
return (
<div
className="modal-backdrop"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal__header">
<h2>Удалить импорт</h2>
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
&times;
</button>
</div>
<div className="modal__body">
<p className="danger-note">
Будут удалены все операции этого импорта ({imp.importedCount}{' '}
шт.): {imp.bank} / {accountLabel}
</p>
{error && <div className="alert alert--error">{error}</div>}
<p>Действие необратимо.</p>
</div>
<div className="modal__footer">
<button
type="button"
className="button button--danger"
onClick={handleConfirm}
disabled={loading}
>
{loading ? 'Удаление…' : 'Удалить'}
</button>
<button type="button" className="button button--secondary" onClick={onClose}>
Отмена
</button>
</div>
</div>
</div>
);
}

View File

@@ -24,6 +24,14 @@ function extractPattern(description: string): string {
.slice(0, 50);
}
function getCommissionAmountSigned(transaction: Transaction): number {
if (transaction.commission === 0) return 0;
const isCashbackIncome =
transaction.amountSigned === 0 &&
transaction.description.toLowerCase().includes('зачисление');
return isCashbackIncome ? transaction.commission : -transaction.commission;
}
export function EditTransactionModal({
transaction,
categories,
@@ -74,37 +82,48 @@ export function EditTransactionModal({
};
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-backdrop"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div className="modal__header">
<h2>Редактирование операции</h2>
<button className="btn-close" onClick={onClose}>
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
&times;
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
{error && <div className="alert alert-error">{error}</div>}
<div className="modal__body">
{error && <div className="alert alert--error">{error}</div>}
<div className="modal-tx-info">
<div className="modal-tx-row">
<span className="modal-tx-label">Дата</span>
<div className="transaction-preview">
<div className="transaction-preview__row">
<span className="transaction-preview__label">Дата</span>
<span>{formatDateTime(transaction.operationAt)}</span>
</div>
<div className="modal-tx-row">
<span className="modal-tx-label">Сумма</span>
<div className="transaction-preview__row">
<span className="transaction-preview__label">Сумма</span>
<span>{formatAmount(transaction.amountSigned)}</span>
</div>
<div className="modal-tx-row">
<span className="modal-tx-label">Описание</span>
<span className="modal-tx-description">
{transaction.commission !== 0 && (
<div className="transaction-preview__row">
<span className="transaction-preview__label">Комиссия</span>
<span>{formatAmount(getCommissionAmountSigned(transaction))}</span>
</div>
)}
<div className="transaction-preview__row">
<span className="transaction-preview__label">Описание</span>
<span className="transaction-preview__description">
{transaction.description}
</span>
</div>
</div>
<div className="form-group">
<div className="field">
<label htmlFor="edit-category">Категория</label>
<select
id="edit-category"
@@ -120,7 +139,7 @@ export function EditTransactionModal({
</select>
</div>
<div className="form-group">
<div className="field">
<label htmlFor="edit-comment">Комментарий</label>
<textarea
id="edit-comment"
@@ -133,7 +152,7 @@ export function EditTransactionModal({
<div className="form-divider" />
<div className="form-group form-group-checkbox">
<div className="field field--checkbox">
<label>
<input
type="checkbox"
@@ -146,7 +165,7 @@ export function EditTransactionModal({
{createRule && (
<>
<div className="form-group">
<div className="field">
<label htmlFor="edit-pattern">
Шаблон (ключевая строка)
</label>
@@ -158,7 +177,7 @@ export function EditTransactionModal({
maxLength={200}
/>
</div>
<div className="form-group form-group-checkbox">
<div className="field field--checkbox">
<label>
<input
type="checkbox"
@@ -174,17 +193,17 @@ export function EditTransactionModal({
)}
</div>
<div className="modal-footer">
<div className="modal__footer">
<button
type="button"
className="btn btn-secondary"
className="button button--secondary"
onClick={onClose}
>
Отмена
</button>
<button
type="submit"
className="btn btn-primary"
className="button button--primary"
disabled={saving}
>
{saving ? 'Сохранение...' : 'Сохранить'}

View File

@@ -2,7 +2,6 @@ import { useState, useRef } from 'react';
import type { ImportStatementResponse } from '@family-budget/shared';
import { importStatement } from '../api/import';
import { updateAccount } from '../api/accounts';
import { useImport } from '../context/ImportContext';
interface Props {
onClose: () => void;
@@ -10,19 +9,13 @@ interface Props {
}
export function ImportModal({ onClose, onDone }: Props) {
const { importState, startImport, clearImport } = useImport();
const [jsonResult, setJsonResult] = useState<ImportStatementResponse | null>(null);
const [jsonError, setJsonError] = useState('');
const [jsonLoading, setJsonLoading] = useState(false);
const [result, setResult] = useState<ImportStatementResponse | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [alias, setAlias] = useState('');
const [aliasSaved, setAliasSaved] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const result = importState.result ?? jsonResult;
const error = importState.error || jsonError;
const loading = importState.active || jsonLoading;
const handleFileChange = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
@@ -35,47 +28,26 @@ export function ImportModal({ onClose, onDone }: Props) {
const isJson = type === 'application/json' || name.endsWith('.json');
if (!isPdf && !isJson) {
setJsonError('Допустимы только файлы PDF или JSON');
setError('Допустимы только файлы PDF или JSON');
return;
}
setJsonError('');
setJsonResult(null);
setLoading(true);
setError('');
setResult(null);
if (isPdf) {
startImport(file);
return;
}
setJsonLoading(true);
try {
const resp = await importStatement(file);
setJsonResult(resp);
setResult(resp);
} catch (err: unknown) {
const msg =
err instanceof Error ? err.message : 'Ошибка импорта';
setJsonError(msg);
setError(msg);
} finally {
setJsonLoading(false);
setLoading(false);
}
};
const handleClose = () => {
if (importState.active) {
if (window.confirm('Импорт продолжится в фоне. Закрыть окно?')) {
onClose();
}
} else {
if (result || importState.error) clearImport();
onClose();
}
};
const handleDone = () => {
clearImport();
onDone();
};
const handleSaveAlias = async () => {
if (!result || !alias.trim()) return;
try {
@@ -86,29 +58,25 @@ export function ImportModal({ onClose, onDone }: Props) {
}
};
const stageLabel = (stage: string) => {
switch (stage) {
case 'pdf': return 'Извлечение текста...';
case 'llm': return 'Конвертация через LLM...';
case 'import': return 'Сохранение в базу...';
default: return 'Обработка...';
}
};
return (
<div className="modal-overlay" onClick={handleClose}>
<div
className="modal-backdrop"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div className="modal__header">
<h2>Импорт выписки</h2>
<button className="btn-close" onClick={handleClose}>
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
&times;
</button>
</div>
<div className="modal-body">
{error && <div className="alert alert-error">{error}</div>}
<div className="modal__body">
{error && <div className="alert alert--error">{error}</div>}
{!result && !importState.active && (
{!result && (
<div className="import-upload">
<p>Выберите файл выписки (PDF или JSON, формат 1.0)</p>
<input
@@ -116,36 +84,19 @@ export function ImportModal({ onClose, onDone }: Props) {
type="file"
accept=".pdf,.json,application/pdf,application/json"
onChange={handleFileChange}
className="file-input"
disabled={loading}
className="import-upload__input"
/>
{jsonLoading && (
<div className="import-loading">Импорт...</div>
{loading && (
<div className="import-upload__loading">Импорт...</div>
)}
</div>
)}
{importState.active && (
<div className="import-upload">
<div className="import-progress-modal">
<div className="import-progress-modal-bar">
<div
className="import-progress-modal-fill"
style={{ width: `${importState.progress}%` }}
/>
</div>
<p className="import-progress-modal-label">
{stageLabel(importState.stage)} {importState.progress}%
</p>
</div>
</div>
)}
{result && (
<div className="import-result">
<div className="import-result-icon" aria-hidden="true"></div>
<div className="import-result__icon" aria-hidden="true"></div>
<h3>Импорт завершён</h3>
<table className="import-stats">
<table className="import-result__stats">
<tbody>
<tr>
<td>Счёт</td>
@@ -171,9 +122,9 @@ export function ImportModal({ onClose, onDone }: Props) {
</table>
{result.isNewAccount && !aliasSaved && (
<div className="import-alias">
<div className="import-result__alias">
<label>Алиас для нового счёта</label>
<div className="import-alias-row">
<div className="import-result__alias-row">
<input
type="text"
placeholder="Напр.: Текущий, Накопительный"
@@ -182,7 +133,7 @@ export function ImportModal({ onClose, onDone }: Props) {
maxLength={50}
/>
<button
className="btn btn-sm btn-primary"
className="button button--primary button--small"
onClick={handleSaveAlias}
disabled={!alias.trim()}
>
@@ -193,7 +144,7 @@ export function ImportModal({ onClose, onDone }: Props) {
)}
{aliasSaved && (
<div className="import-alias-saved">
<div className="import-result__alias-saved">
Алиас сохранён
</div>
)}
@@ -201,17 +152,18 @@ export function ImportModal({ onClose, onDone }: Props) {
)}
</div>
<div className="modal-footer">
<div className="modal__footer">
{result ? (
<button className="btn btn-primary" onClick={handleDone}>
<button className="button button--primary" onClick={onDone}>
Готово
</button>
) : (
<button
className="btn btn-secondary"
onClick={handleClose}
className="button button--secondary"
onClick={onClose}
disabled={loading}
>
{importState.active ? 'Свернуть' : 'Отмена'}
Отмена
</button>
)}
</div>

View File

@@ -1,84 +1,26 @@
import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react';
import { useState, useEffect, type ReactNode } from 'react';
import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useImport } from '../context/ImportContext';
function ImportProgressBar() {
const { importState, clearImport } = useImport();
const [visible, setVisible] = useState(false);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const isActive = importState.active;
const isDone = importState.stage === 'done';
const isError = importState.stage === 'error';
const showBar = isActive || isDone || isError;
useEffect(() => {
if (showBar) {
setVisible(true);
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
}
if (isDone || isError) {
hideTimerRef.current = setTimeout(() => {
setVisible(false);
clearImport();
}, 10_000);
}
return () => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
};
}, [showBar, isDone, isError, clearImport]);
const handleClick = useCallback(() => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
setVisible(false);
clearImport();
}, [clearImport]);
if (!visible) return null;
const barClass = isError
? 'import-progress-bar import-progress-bar--error'
: isDone
? 'import-progress-bar import-progress-bar--done'
: 'import-progress-bar';
const labelText = isError
? `Ошибка импорта: ${importState.message}`
: isDone && importState.result
? `Импорт завершён — ${importState.result.imported} операций`
: `${importState.message} ${importState.progress}%`;
return (
<div className={barClass}>
<div
className="import-progress-bar__fill"
style={{ width: `${isDone ? 100 : importState.progress}%` }}
/>
<button
type="button"
className={`import-progress-label ${isDone || isError ? 'import-progress-label--clickable' : ''}`}
onClick={isDone || isError ? handleClick : undefined}
>
{labelText}
</button>
</div>
);
}
import { getBackendVersion } from '../api/version';
export function Layout({ children }: { children: ReactNode }) {
const { user, logout } = useAuth();
const [drawerOpen, setDrawerOpen] = useState(false);
const [beVersion, setBeVersion] = useState<string | null>(null);
const closeDrawer = () => setDrawerOpen(false);
return (
<div className="layout">
<ImportProgressBar />
useEffect(() => {
getBackendVersion()
.then((r) => setBeVersion(r.version))
.catch(() => setBeVersion(null));
}, []);
return (
<div className="app-shell">
<button
type="button"
className="burger-btn"
className="menu-button"
aria-label="Открыть меню"
onClick={() => setDrawerOpen(true)}
>
@@ -91,23 +33,23 @@ export function Layout({ children }: { children: ReactNode }) {
{drawerOpen && (
<div
className="sidebar-overlay"
className="app-shell__overlay"
aria-hidden="true"
onClick={closeDrawer}
/>
)}
<aside className={`sidebar ${drawerOpen ? 'sidebar-open' : ''}`}>
<div className="sidebar-brand">
<span className="sidebar-brand-icon"></span>
<span className="sidebar-brand-text">Семейный бюджет</span>
<aside className={`sidebar ${drawerOpen ? 'sidebar--open' : ''}`}>
<div className="sidebar__brand">
<span className="sidebar__brand-icon"></span>
<span className="sidebar__brand-text">Семейный бюджет</span>
</div>
<nav className="sidebar-nav">
<nav className="sidebar__nav" aria-label="Основная навигация">
<NavLink
to="/history"
className={({ isActive }) =>
`nav-link${isActive ? ' active' : ''}`
`sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}`
}
onClick={closeDrawer}
>
@@ -124,7 +66,7 @@ export function Layout({ children }: { children: ReactNode }) {
<NavLink
to="/analytics"
className={({ isActive }) =>
`nav-link${isActive ? ' active' : ''}`
`sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}`
}
onClick={closeDrawer}
>
@@ -139,7 +81,7 @@ export function Layout({ children }: { children: ReactNode }) {
<NavLink
to="/settings"
className={({ isActive }) =>
`nav-link${isActive ? ' active' : ''}`
`sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}`
}
onClick={closeDrawer}
>
@@ -151,15 +93,23 @@ export function Layout({ children }: { children: ReactNode }) {
</NavLink>
</nav>
<div className="sidebar-footer">
<span className="sidebar-user">{user?.login}</span>
<button className="btn-logout" onClick={() => logout()}>
Выход
</button>
<div className="sidebar__footer">
<div className="sidebar__user-row">
<span className="sidebar__user">{user?.login}</span>
<button className="sidebar__logout" onClick={() => logout()}>
Выход
</button>
</div>
<div className="sidebar__meta">
<span className="sidebar__version">
FE {__FE_VERSION__} · BE {beVersion ?? '…'}
</span>
<span className="sidebar__copyright">© 2025 Семейный бюджет</span>
</div>
</div>
</aside>
<main className="main-content">{children}</main>
<main className="app-shell__main">{children}</main>
</div>
);
}

View File

@@ -20,35 +20,38 @@ export function Pagination({
return (
<div className="pagination">
<div className="pagination-info">
<div className="pagination__info">
{totalItems > 0
? `Показано ${from}${to} из ${totalItems}`
: 'Нет записей'}
</div>
<div className="pagination-controls">
<div className="pagination__controls">
<select
className="pagination-size"
className="pagination__size"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
aria-label="Количество операций на странице"
>
<option value={10}>10</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
<button
className="btn-page"
className="icon-button"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="Предыдущая страница"
>
&larr;
</button>
<span className="pagination-current">
<span className="pagination__current">
{page} / {totalPages || 1}
</span>
<button
className="btn-page"
className="icon-button"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="Следующая страница"
>
&rarr;
</button>

View File

@@ -85,13 +85,13 @@ export function PeriodSelector({ period, onChange }: Props) {
};
return (
<div className="period-selector">
<div className="period-modes">
<div className="period-picker">
<div className="segmented-control">
{(['week', 'month', 'year', 'custom'] as PeriodMode[]).map(
(m) => (
<button
key={m}
className={`btn-preset ${period.mode === m ? 'active' : ''}`}
className={`segmented-control__button ${period.mode === m ? 'segmented-control__button--active' : ''}`}
onClick={() => setMode(m)}
>
{MODE_LABELS[m]}
@@ -100,13 +100,18 @@ export function PeriodSelector({ period, onChange }: Props) {
)}
</div>
<div className="period-nav">
<div className="period-picker__nav">
{period.mode !== 'custom' && (
<button className="btn-page" onClick={() => navigate(-1)}>
<button
className="icon-button"
onClick={() => navigate(-1)}
aria-label="Предыдущий период"
title="Предыдущий период"
>
&larr;
</button>
)}
<div className="period-dates">
<div className="period-picker__dates">
<input
type="date"
value={period.from}
@@ -114,7 +119,7 @@ export function PeriodSelector({ period, onChange }: Props) {
onChange({ ...period, mode: 'custom', from: e.target.value })
}
/>
<span className="filter-separator">&mdash;</span>
<span className="period-picker__separator">&mdash;</span>
<input
type="date"
value={period.to}
@@ -124,7 +129,12 @@ export function PeriodSelector({ period, onChange }: Props) {
/>
</div>
{period.mode !== 'custom' && (
<button className="btn-page" onClick={() => navigate(1)}>
<button
className="icon-button"
onClick={() => navigate(1)}
aria-label="Следующий период"
title="Следующий период"
>
&rarr;
</button>
)}

View File

@@ -51,7 +51,7 @@ export function RulesList() {
};
if (loading) {
return <div className="section-loading">Загрузка...</div>;
return <div className="state state--loading">Загрузка...</div>;
}
return (
@@ -61,10 +61,10 @@ export function RulesList() {
<tr>
<th>Шаблон</th>
<th>Категория</th>
<th className="th-center">Приоритет</th>
<th className="th-center">Подтверждение</th>
<th className="data-table__head-cell data-table__head-cell--center">Приоритет</th>
<th className="data-table__head-cell data-table__head-cell--center">Подтверждение</th>
<th>Создано</th>
<th className="th-center">Активно</th>
<th className="data-table__head-cell data-table__head-cell--center">Активно</th>
<th></th>
</tr>
</thead>
@@ -72,35 +72,38 @@ export function RulesList() {
{rules.map((r) => (
<tr
key={r.id}
className={!r.isActive ? 'row-inactive' : ''}
className={!r.isActive ? 'data-table__row--inactive' : ''}
>
<td>
<code>{r.pattern}</code>
</td>
<td>{r.categoryName}</td>
<td className="td-center">{r.priority}</td>
<td className="td-center">
<td className="data-table__cell data-table__cell--center">{r.priority}</td>
<td className="data-table__cell data-table__cell--center">
{r.requiresConfirmation ? 'Да' : 'Нет'}
</td>
<td className="td-nowrap">
<td className="data-table__cell data-table__cell--nowrap">
{formatDate(r.createdAt)}
</td>
<td className="td-center">
<td className="data-table__cell data-table__cell--center">
<button
className={`toggle ${r.isActive ? 'toggle-on' : 'toggle-off'}`}
className={`switch-button ${r.isActive ? 'switch-button--on' : 'switch-button--off'}`}
onClick={() => handleToggle(r)}
title={
r.isActive ? 'Деактивировать' : 'Активировать'
}
aria-label={
r.isActive ? 'Деактивировать правило' : 'Активировать правило'
}
>
{r.isActive ? 'Вкл' : 'Выкл'}
</button>
</td>
<td>
<div className="rules-actions">
<div className="rules-list__actions">
{r.isActive && (
<button
className="btn btn-sm btn-secondary"
className="button button--secondary button--small"
onClick={() => handleApply(r.id)}
disabled={applyingId === r.id}
>
@@ -108,7 +111,7 @@ export function RulesList() {
</button>
)}
{applyResult?.id === r.id && (
<span className="apply-result">
<span className="rules-list__result">
Применено: {applyResult.count}
</span>
)}
@@ -118,7 +121,7 @@ export function RulesList() {
))}
{rules.length === 0 && (
<tr>
<td colSpan={7} className="td-center text-muted">
<td colSpan={7} className="data-table__cell data-table__cell--center muted-text">
Нет правил
</td>
</tr>

View File

@@ -6,47 +6,61 @@ interface Props {
}
export function SummaryCards({ summary }: Props) {
const balanceModifier = summary.net >= 0 ? 'positive' : 'negative';
return (
<div className="summary-cards">
<div className="summary-card summary-card-income">
<div className="summary-label">Доходы</div>
<div className="summary-value">
<div className="summary">
<div className="summary__card summary__card--income">
<div className="summary__label">Доходы</div>
<div className="summary__value">
{formatAmount(summary.totalIncome)}
</div>
</div>
<div className="summary-card summary-card-expense">
<div className="summary-label">Расходы</div>
<div className="summary-value">
<div className="summary__card summary__card--expense">
<div className="summary__label">Расходы</div>
<div className="summary__value">
{formatAmount(summary.totalExpense)}
</div>
</div>
<div
className={`summary-card ${summary.net >= 0 ? 'summary-card-positive' : 'summary-card-negative'}`}
className={`summary__card summary__card--${balanceModifier}`}
>
<div className="summary-label">Баланс</div>
<div className="summary-value">
<div className="summary__label">Баланс</div>
<div className="summary__value">
{formatAmount(summary.net)}
</div>
</div>
<div className="summary__card summary__card--investments">
<div className="summary__label">На инвестиции</div>
<div className="summary__value">
{formatAmount(summary.investmentOutflow)}
</div>
{summary.investmentIncomeExcluded > 0 && (
<div className="summary__subvalue">
Исключено из доходов: {formatAmount(summary.investmentIncomeExcluded)}
</div>
)}
</div>
{summary.topCategories.length > 0 && (
<div className="summary-card summary-card-top">
<div className="summary-label">Топ расходов</div>
<div className="summary-top-list">
<div className="summary__card summary__card--top">
<div className="summary__label">Топ расходов</div>
<div className="summary__top-list">
{summary.topCategories.map((cat) => (
<div
key={cat.categoryId}
className="top-category-item"
className="summary__top-item"
>
<span className="top-category-name">
<span className="summary__top-name">
{cat.categoryName}
</span>
<span className="top-category-amount">
<span className="summary__top-amount">
{formatAmount(cat.amount)}
</span>
<span className="top-category-share">
<span className="summary__top-share">
{(cat.share * 100).toFixed(0)}%
</span>
</div>

View File

@@ -26,19 +26,20 @@ export function TimeseriesChart({ data }: Props) {
const chartHeight = isMobile ? 250 : 300;
if (data.length === 0) {
return <div className="chart-empty">Нет данных за период</div>;
return <div className="state state--empty">Нет данных за период</div>;
}
const chartData = data.map((item) => ({
period: item.periodStart,
Расходы: Math.abs(item.expenseAmount) / 100,
Доходы: item.incomeAmount / 100,
Инвестиции: Math.abs(item.investmentOutflow) / 100,
}));
return (
<ResponsiveContainer width="100%" height={chartHeight}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<CartesianGrid strokeDasharray="3 3" stroke="#e7ded2" vertical={false} />
<XAxis
dataKey="period"
tickFormatter={(v: string) => {
@@ -46,27 +47,42 @@ export function TimeseriesChart({ data }: Props) {
return `${d.getDate()}.${String(d.getMonth() + 1).padStart(2, '0')}`;
}}
fontSize={12}
stroke="#64748b"
stroke="#7d7164"
tickLine={false}
axisLine={false}
/>
<YAxis
tickFormatter={(v: number) =>
v >= 1000 ? `${(v / 1000).toFixed(0)}к` : String(v)
}
fontSize={12}
stroke="#64748b"
stroke="#7d7164"
tickLine={false}
axisLine={false}
/>
<Tooltip
formatter={(value: number) => rubFormatter.format(value)}
cursor={{ fill: 'rgba(129, 93, 58, 0.08)' }}
contentStyle={{
border: '1px solid #e7ded2',
borderRadius: 8,
boxShadow: '0 16px 36px rgba(54, 42, 30, 0.14)',
}}
/>
<Legend />
<Bar
dataKey="Расходы"
fill="#ef4444"
fill="#e85d3f"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="Доходы"
fill="#10b981"
fill="#0f9f7f"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="Инвестиции"
fill="#d89b17"
radius={[4, 4, 0, 0]}
/>
</BarChart>

View File

@@ -115,28 +115,29 @@ export function TransactionFilters({
};
return (
<div className="filters-panel">
<div className="filters-row">
<div className="filter-group">
<div className="filters">
<div className="filters__row">
<div className="field field--period">
<label>Период</label>
<div className="filter-dates-wrap">
<div className="filters__date-control">
{filters.periodMode !== 'custom' && (
<button
type="button"
className="btn-page"
className="icon-button"
onClick={() => navigate(-1)}
aria-label="Предыдущий период"
title="Предыдущий период"
>
&larr;
</button>
)}
<div className="filter-dates">
<div className="filters__dates">
<input
type="date"
value={filters.from}
onChange={(e) => handleDateChange('from', e.target.value)}
/>
<span className="filter-separator">&mdash;</span>
<span className="filters__separator">&mdash;</span>
<input
type="date"
value={filters.to}
@@ -146,29 +147,30 @@ export function TransactionFilters({
{filters.periodMode !== 'custom' && (
<button
type="button"
className="btn-page"
className="icon-button"
onClick={() => navigate(1)}
aria-label="Следующий период"
title="Следующий период"
>
&rarr;
</button>
)}
</div>
<div className="filter-presets">
<div className="segmented-control segmented-control--compact">
<button
className={`btn-preset ${filters.periodMode === 'week' ? 'active' : ''}`}
className={`segmented-control__button ${filters.periodMode === 'week' ? 'segmented-control__button--active' : ''}`}
onClick={() => applyPreset('week')}
>
Неделя
</button>
<button
className={`btn-preset ${filters.periodMode === 'month' ? 'active' : ''}`}
className={`segmented-control__button ${filters.periodMode === 'month' ? 'segmented-control__button--active' : ''}`}
onClick={() => applyPreset('month')}
>
Месяц
</button>
<button
className={`btn-preset ${filters.periodMode === 'year' ? 'active' : ''}`}
className={`segmented-control__button ${filters.periodMode === 'year' ? 'segmented-control__button--active' : ''}`}
onClick={() => applyPreset('year')}
>
Год
@@ -176,7 +178,7 @@ export function TransactionFilters({
</div>
</div>
<div className="filter-group">
<div className="field">
<label>Счёт</label>
<select
value={filters.accountId}
@@ -191,7 +193,7 @@ export function TransactionFilters({
</select>
</div>
<div className="filter-group">
<div className="field">
<label>Тип</label>
<select
value={filters.direction}
@@ -204,7 +206,7 @@ export function TransactionFilters({
</select>
</div>
<div className="filter-group">
<div className="field">
<label>Категория</label>
<select
value={filters.categoryId}
@@ -220,8 +222,8 @@ export function TransactionFilters({
</div>
</div>
<div className="filters-row">
<div className="filter-group filter-group-wide">
<div className="filters__row">
<div className="field field--wide">
<label>Поиск</label>
<input
type="text"
@@ -231,7 +233,7 @@ export function TransactionFilters({
/>
</div>
<div className="filter-group">
<div className="field">
<label>Сумма от ()</label>
<input
type="number"
@@ -241,7 +243,7 @@ export function TransactionFilters({
/>
</div>
<div className="filter-group">
<div className="field">
<label>Сумма до ()</label>
<input
type="number"
@@ -251,7 +253,7 @@ export function TransactionFilters({
/>
</div>
<div className="filter-group filter-group-checkbox">
<div className="field field--checkbox">
<label>
<input
type="checkbox"
@@ -262,9 +264,9 @@ export function TransactionFilters({
</label>
</div>
<div className="filter-group">
<div className="field">
<label>Сортировка</label>
<div className="filter-sort">
<div className="filters__sort">
<select
value={filters.sortBy}
onChange={(e) =>
@@ -275,7 +277,7 @@ export function TransactionFilters({
<option value="amount">По сумме</option>
</select>
<button
className="btn-sort-order"
className="icon-button"
onClick={() =>
set(
'sortOrder',
@@ -287,6 +289,11 @@ export function TransactionFilters({
? 'По возрастанию'
: 'По убыванию'
}
aria-label={
filters.sortOrder === 'asc'
? 'Сортировать по убыванию'
: 'Сортировать по возрастанию'
}
>
{filters.sortOrder === 'asc' ? '↑' : '↓'}
</button>

View File

@@ -8,11 +8,18 @@ interface Props {
}
const DIRECTION_CLASSES: Record<string, string> = {
income: 'amount-income',
expense: 'amount-expense',
transfer: 'amount-transfer',
income: 'money-amount--income',
expense: 'money-amount--expense',
transfer: 'money-amount--transfer',
};
function getCommissionAmountSigned(tx: Transaction): number {
if (tx.commission === 0) return 0;
const isCashbackIncome =
tx.amountSigned === 0 && tx.description.toLowerCase().includes('зачисление');
return isCashbackIncome ? tx.commission : -tx.commission;
}
function TransactionCard({
tx,
onEdit,
@@ -26,34 +33,39 @@ function TransactionCard({
return (
<div
className={`transaction-card ${isUnconfirmed ? 'row-unconfirmed' : ''}`}
className={`transaction-card ${isUnconfirmed ? 'transaction-card--unconfirmed' : ''}`}
>
<div className="transaction-card-header">
<span className="transaction-card-date">
<div className="transaction-card__header">
<span className="transaction-card__date">
{formatDateTime(tx.operationAt)}
</span>
<span className={`transaction-card-amount ${directionClass}`}>
<span className={`money-amount transaction-card__amount ${directionClass}`}>
{formatAmount(tx.amountSigned)}
</span>
</div>
<div className="transaction-card-body">
<span className="description-text">{tx.description}</span>
{tx.commission !== 0 && (
<div className="transaction-card__commission">
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
</div>
)}
<div className="transaction-card__body">
<span className="transaction-card__description">{tx.description}</span>
{tx.comment && (
<span className="comment-badge" title={tx.comment}>
<span className="comment-indicator" title={tx.comment}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</span>
)}
</div>
<div className="transaction-card-footer">
<span className="transaction-card-meta">
<div className="transaction-card__footer">
<span className="transaction-card__meta">
{tx.accountAlias || '—'} · {tx.categoryName || '—'}
</span>
<div className="transaction-card-actions">
<div className="transaction-card__actions">
{tx.categoryId != null && !tx.isCategoryConfirmed && (
<span
className="badge badge-warning"
className="badge badge--warning"
title="Категория не подтверждена"
>
?
@@ -61,8 +73,9 @@ function TransactionCard({
)}
<button
type="button"
className="btn-icon btn-icon-touch"
className="icon-button icon-button--touch"
onClick={() => onEdit(tx)}
aria-label="Редактировать операцию"
title="Редактировать"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -78,16 +91,16 @@ function TransactionCard({
export function TransactionTable({ transactions, loading, onEdit }: Props) {
if (loading) {
return <div className="table-loading">Загрузка операций...</div>;
return <div className="state state--loading">Загрузка операций...</div>;
}
if (transactions.length === 0) {
return <div className="table-empty">Операции не найдены</div>;
return <div className="state state--empty">Операции не найдены</div>;
}
return (
<>
<div className="table-wrapper table-desktop">
<div className="table-shell table-shell--desktop">
<table className="data-table">
<thead>
<tr>
@@ -96,7 +109,7 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
<th>Сумма</th>
<th>Описание</th>
<th>Категория</th>
<th className="th-center">Статус</th>
<th className="data-table__head-cell data-table__head-cell--center">Статус</th>
<th></th>
</tr>
</thead>
@@ -106,38 +119,43 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
key={tx.id}
className={
!tx.isCategoryConfirmed && tx.categoryId
? 'row-unconfirmed'
? 'data-table__row--unconfirmed'
: ''
}
>
<td className="td-nowrap">
<td className="data-table__cell data-table__cell--nowrap">
{formatDateTime(tx.operationAt)}
</td>
<td className="td-nowrap">{tx.accountAlias || '—'}</td>
<td className="data-table__cell data-table__cell--nowrap">{tx.accountAlias || '—'}</td>
<td
className={`td-nowrap td-amount ${DIRECTION_CLASSES[tx.direction] ?? ''}`}
className={`data-table__cell data-table__cell--nowrap money-amount ${DIRECTION_CLASSES[tx.direction] ?? ''}`}
>
{formatAmount(tx.amountSigned)}
<div>{formatAmount(tx.amountSigned)}</div>
{tx.commission !== 0 && (
<div className="data-table__subtext">
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
</div>
)}
</td>
<td className="td-description">
<span className="description-text">{tx.description}</span>
<td className="data-table__cell data-table__cell--description">
<span className="data-table__description">{tx.description}</span>
{tx.comment && (
<span className="comment-badge" title={tx.comment}>
<span className="comment-indicator" title={tx.comment}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</span>
)}
</td>
<td className="td-nowrap">
<td className="data-table__cell data-table__cell--nowrap">
{tx.categoryName || (
<span className="text-muted"></span>
<span className="muted-text"></span>
)}
</td>
<td className="td-center">
<td className="data-table__cell data-table__cell--center">
{tx.categoryId != null && !tx.isCategoryConfirmed && (
<span
className="badge badge-warning"
className="badge badge--warning"
title="Категория не подтверждена"
>
?
@@ -146,8 +164,9 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
</td>
<td>
<button
className="btn-icon"
className="icon-button"
onClick={() => onEdit(tx)}
aria-label="Редактировать операцию"
title="Редактировать"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -162,7 +181,7 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
</table>
</div>
<div className="transaction-cards transaction-mobile">
<div className="transaction-list transaction-list--mobile">
{transactions.map((tx) => (
<TransactionCard key={tx.id} tx={tx} onEdit={onEdit} />
))}

View File

@@ -10,7 +10,7 @@ import { getMe, login as apiLogin, logout as apiLogout } from '../api/auth';
import { setOnUnauthorized } from '../api/client';
interface AuthState {
user: { login: string } | null;
user: { login: string; backendVersion: string } | null;
loading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<void>;
@@ -20,7 +20,7 @@ interface AuthState {
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<{ login: string } | null>(null);
const [user, setUser] = useState<{ login: string; backendVersion: string } | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -31,7 +31,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
setOnUnauthorized(clearUser);
getMe()
.then((me) => setUser({ login: me.login }))
.then((me) => setUser({ login: me.login, backendVersion: me.backendVersion }))
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, [clearUser]);
@@ -41,7 +41,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
await apiLogin({ login: username, password });
const me = await getMe();
setUser({ login: me.login });
setUser({ login: me.login, backendVersion: me.backendVersion });
} catch (e: unknown) {
const msg = e instanceof Error && e.message === 'Failed to fetch'
? 'Сервер недоступен. Проверьте, что backend запущен и NPM проксирует /api на порт 3000.'

View File

@@ -1,105 +0,0 @@
import {
createContext,
useContext,
useState,
useCallback,
useRef,
type ReactNode,
} from 'react';
import type { ImportStatementResponse } from '@family-budget/shared';
import { importStatementStream, type SseEvent } from '../api/import';
export interface ImportProgress {
active: boolean;
stage: string;
progress: number;
message: string;
result?: ImportStatementResponse;
error?: string;
}
interface ImportContextValue {
importState: ImportProgress;
startImport: (file: File) => void;
clearImport: () => void;
}
const INITIAL: ImportProgress = {
active: false,
stage: '',
progress: 0,
message: '',
};
const ImportContext = createContext<ImportContextValue | null>(null);
export function ImportProvider({ children }: { children: ReactNode }) {
const [importState, setImportState] = useState<ImportProgress>(INITIAL);
const runningRef = useRef(false);
const startImport = useCallback((file: File) => {
if (runningRef.current) return;
runningRef.current = true;
setImportState({
active: true,
stage: 'pdf',
progress: 0,
message: 'Загрузка файла...',
});
importStatementStream(file, (event: SseEvent) => {
if (event.stage === 'done') {
setImportState({
active: false,
stage: 'done',
progress: 100,
message: 'Импорт завершён',
result: event.result,
});
runningRef.current = false;
} else if (event.stage === 'error') {
setImportState({
active: false,
stage: 'error',
progress: 0,
message: event.message,
error: event.message,
});
runningRef.current = false;
} else {
setImportState({
active: true,
stage: event.stage,
progress: event.progress,
message: event.message,
});
}
}).catch((err) => {
setImportState({
active: false,
stage: 'error',
progress: 0,
message: err instanceof Error ? err.message : 'Ошибка импорта',
error: err instanceof Error ? err.message : 'Ошибка импорта',
});
runningRef.current = false;
});
}, []);
const clearImport = useCallback(() => {
setImportState(INITIAL);
}, []);
return (
<ImportContext.Provider value={{ importState, startImport, clearImport }}>
{children}
</ImportContext.Provider>
);
}
export function useImport(): ImportContextValue {
const ctx = useContext(ImportContext);
if (!ctx) throw new Error('useImport must be used within ImportProvider');
return ctx;
}

View File

@@ -88,14 +88,17 @@ export function AnalyticsPage() {
return (
<div className="page">
<div className="page-header">
<h1>Аналитика</h1>
<div className="page__header">
<div>
<p className="page__eyebrow">Обзор периода</p>
<h1 className="page__title">Аналитика</h1>
</div>
</div>
<div className="analytics-controls">
<div className="analytics-panel">
<PeriodSelector period={period} onChange={setPeriod} />
<div className="analytics-filters">
<div className="filter-group">
<div className="analytics-panel__filters">
<div className="field">
<label>Счёт</label>
<select
value={accountId}
@@ -109,7 +112,7 @@ export function AnalyticsPage() {
))}
</select>
</div>
<div className="filter-group filter-group-checkbox">
<div className="field field--checkbox">
<label>
<input
type="checkbox"
@@ -123,11 +126,11 @@ export function AnalyticsPage() {
</div>
{loading ? (
<div className="section-loading">Загрузка данных...</div>
<div className="state state--loading">Загрузка данных...</div>
) : (
<>
{summary && <SummaryCards summary={summary} />}
<div className="analytics-charts">
<div className="analytics-grid">
<div className="chart-card">
<h3>Динамика</h3>
<TimeseriesChart data={timeseries} />

View File

@@ -204,12 +204,20 @@ export function HistoryPage() {
return (
<div className="page">
<div className="page-header">
<h1>История операций</h1>
<div className="page__header">
<div>
<p className="page__eyebrow">Операции</p>
<h1 className="page__title">История операций</h1>
</div>
<button
className="btn btn-primary"
className="button button--primary"
onClick={() => setShowImport(true)}
>
<svg className="button__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Импорт выписки
</button>
</div>

View File

@@ -20,16 +20,16 @@ export function LoginPage() {
};
return (
<div className="login-page">
<div className="login-card">
<div className="login-header">
<span className="login-icon"></span>
<div className="login">
<div className="login__panel">
<div className="login__header">
<span className="login__icon"></span>
<h1>Семейный бюджет</h1>
<p>Войдите для продолжения</p>
</div>
<form onSubmit={handleSubmit} className="login-form">
{error && <div className="alert alert-error">{error}</div>}
<div className="form-group">
<form onSubmit={handleSubmit} className="login__form">
{error && <div className="alert alert--error">{error}</div>}
<div className="field">
<label htmlFor="login">Логин</label>
<input
id="login"
@@ -41,7 +41,7 @@ export function LoginPage() {
autoFocus
/>
</div>
<div className="form-group">
<div className="field">
<label htmlFor="password">Пароль</label>
<input
id="password"
@@ -54,7 +54,7 @@ export function LoginPage() {
</div>
<button
type="submit"
className="btn btn-primary btn-block"
className="button button--primary button--block"
disabled={submitting}
>
{submitting ? 'Вход...' : 'Войти'}

View File

@@ -11,38 +11,41 @@ export function SettingsPage() {
return (
<div className="page">
<div className="page-header">
<h1>Настройки</h1>
<div className="page__header">
<div>
<p className="page__eyebrow">Справочники</p>
<h1 className="page__title">Настройки</h1>
</div>
</div>
<div className="tabs">
<button
className={`tab ${tab === 'accounts' ? 'active' : ''}`}
className={`tabs__button ${tab === 'accounts' ? 'tabs__button--active' : ''}`}
onClick={() => setTab('accounts')}
>
Счета
</button>
<button
className={`tab ${tab === 'categories' ? 'active' : ''}`}
className={`tabs__button ${tab === 'categories' ? 'tabs__button--active' : ''}`}
onClick={() => setTab('categories')}
>
Категории
</button>
<button
className={`tab ${tab === 'rules' ? 'active' : ''}`}
className={`tabs__button ${tab === 'rules' ? 'tabs__button--active' : ''}`}
onClick={() => setTab('rules')}
>
Правила
</button>
<button
className={`tab ${tab === 'data' ? 'active' : ''}`}
className={`tabs__button ${tab === 'data' ? 'tabs__button--active' : ''}`}
onClick={() => setTab('data')}
>
Данные
</button>
</div>
<div className="tab-content">
<div className="tabs__content">
{tab === 'accounts' && <AccountsList />}
{tab === 'categories' && <CategoriesList />}
{tab === 'rules' && <RulesList />}

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,3 @@
/// <reference types="vite/client" />
declare const __FE_VERSION__: string;

View File

@@ -1,8 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(
readFileSync(join(__dirname, 'package.json'), 'utf-8'),
) as { version: string };
export default defineConfig({
plugins: [react()],
define: {
__FE_VERSION__: JSON.stringify(pkg.version),
},
server: {
port: 5173,
proxy: {

11
package-lock.json generated
View File

@@ -64,7 +64,7 @@
},
"frontend": {
"name": "@family-budget/frontend",
"version": "0.1.0",
"version": "0.8.6",
"dependencies": {
"@family-budget/shared": "*",
"react": "^19.0.0",
@@ -111,6 +111,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1394,6 +1395,7 @@
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -1472,6 +1474,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1611,6 +1614,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2734,6 +2738,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.12.0",
@@ -2831,6 +2836,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2980,6 +2986,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -2989,6 +2996,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -4100,6 +4108,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

View File

@@ -1,6 +1,6 @@
{
"name": "family-budget",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"workspaces": [
"shared",

View File

@@ -1,75 +1,231 @@
Ты — конвертер банковских выписок. Твоя задача: извлечь данные из прикреплённого PDF банковской выписки и вернуть строго один валидный JSON-объект в формате ниже. Никакого текста до или после JSON, только сам объект.
# Инструкция агента: конвертация банковской выписки ВТБ (PDF → JSON)
## Структура выходного JSON
## Цель
Преобразовать PDF-выписку банка ВТБ в JSON-файл строго по схеме 1.0.
На выходе — валидный JSON-файл без потерь операций, с правильными суммами в копейках и корректной проверкой баланса.
---
## Схема JSON (schema 1.0)
```json
{
"schemaVersion": "1.0",
"bank": "<название банка из выписки>",
"bank": "VTB",
"statement": {
"accountNumber": "<номер счёта, только цифры, без пробелов>",
"accountNumber": "string",
"currency": "RUB",
"openingBalance": <число в копейках, целое>,
"closingBalance": <число в копейках, целое>,
"exportedAt": "<дата экспорта в формате ISO 8601 с offset, например 2026-02-27T13:23:00+03:00>"
"openingBalance": integer,
"closingBalance": integer,
"exportedAt": "ISO 8601 string"
},
"transactions": [
{
"operationAt": "<дата и время операции в формате ISO 8601 с offset>",
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>,
"commission": <число, целое, >= 0, в копейках>,
"description": "<полное описание операции из выписки>"
"operationAt": "ISO 8601 string",
"amountSigned": integer,
"commission": integer,
"description": "string"
}
]
}
```
## Правила конвертации
---
1. **Суммы** — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456.
## Шаг 1. Извлечение данных из PDF
2. **amountSigned**:
- Приход (зачисление, пополнение) — положительное число.
- Расход (списание, оплата) — отрицательное число.
- Переводы — знак в зависимости от направления движения на счёт.
### 1.1 Шапка выписки
3. **operationAt** — дата и время операции. Если время не указано, используй 00:00:00. Обязательно указывай offset (+03:00 для МСК).
Извлечь из заголовочного блока:
4. **commission** — комиссия по операции. Если не указана — 0.
| Поле PDF | Поле JSON | Примечание |
|---------------------------|----------------------------------|-----------------------------------|
| Номер счёта | `statement.accountNumber` | строка, без пробелов |
| Баланс на начало периода | `statement.openingBalance` | перевести в копейки (× 100) |
| Баланс на конец периода | `statement.closingBalance` | перевести в копейки (× 100) |
| Поступления | только для валидации | не записывать в JSON |
| Расходные операции | только для валидации | не записывать в JSON |
| Период выписки | используется для валидации | формат ДД.ММ.ГГГГ ДД.ММ.ГГГГ |
5. **description** — полный текст операции как в выписке (назначение платежа, магазин, получатель и т.п.). Не сокращай и не меняй формулировки.
### 1.2 Поле `exportedAt`
6. **accountNumber** — только цифры, без пробелов и дефисов (например: 40817810825104025611).
Взять дату и время **первой строки таблицы операций** (самая поздняя по дате операции).
Формат: ISO 8601 с московским часовым поясом: `YYYY-MM-DDTHH:MM:SS+03:00`
7. **openingBalance / closingBalance** — начальный и конечный остаток по счёту в копейках.
---
8. **bank** — краткое название банка (VTB, Sberbank, Тинькофф и т.п.).
## Шаг 2. Извлечение операций
9. **exportedAt** — дата формирования выписки. Если неизвестна — возьми дату последней операции в выписке.
### 2.1 Столбцы таблицы в PDF
10. **Порядок транзакций** — сохраняй хронологический порядок из выписки (обычно от старых к новым).
Каждая строка операции содержит:
## Требования
| Столбец PDF | Описание |
|-------------------------------------|------------------------------------------------|
| Дата и время операции | дата + время совершения операции |
| Дата обработки банком | дата, когда банк обработал операцию |
| Сумма операции в валюте операции | знаковая сумма (`-` = расход, без знака = приход) |
| Приход (в валюте счёта) | сумма, если поступление |
| Расход (в валюте счёта) | сумма, если списание |
| Комиссия | комиссия банка |
| Описание операции | текстовое описание |
- Массив `transactions` не должен быть пустым.
- Все числа — целые.
- Даты — строго в формате ISO 8601 с offset.
- currency всегда "RUB".
- schemaVersion всегда "1.0".
### 2.2 Правила маппинга
## Пример одной транзакции
**`operationAt`** — из столбца «Дата и время операции», формат ISO 8601 + московский TZ:
```
29.03.2026 20:38:01 → 2026-03-29T20:38:01+03:00
```
Выписка: «26.02.2026 14:06 | -500,00 ₽ | 0,00 | Оплата товаров. PAVELETSKAYA по карте *8214»
**`amountSigned`** — сумма в **копейках** (целое число):
- Если операция — **приход**: значение **положительное**
- Если операция — **расход**: значение **отрицательное**
- Допустим специальный кейс: `amountSigned = 0`, **только если** `commission > 0` и в `description` есть подстрока **«Зачисление»** (без учёта регистра)
- Определять знак по столбцу «Приход»/«Расход», а не по знаку в PDF (там `-` стоит у расходов, но надёжнее смотреть, в какой колонке стоит сумма)
- Перевод в копейки: умножить на 100 и округлить до целого (`round(amount * 100)`)
- Разделитель дробной части в PDF — точка или запятая — оба варианта обрабатывать
**`commission`** — комиссия в **копейках** (целое число):
- Если в PDF стоит `0.00` → записать `0`
- Если указана ненулевая комиссия → перевести в копейки аналогично `amountSigned`
- Комиссия всегда **неотрицательная**
**`description`** — полный текст описания операции, строка:
- Убрать лишние пробелы и переносы строк (привести к одной строке)
- Не обрезать и не сокращать
- Сохранить кириллицу и спецсимволы как есть
### 2.3 Порядок операций
Операции записывать **в том же порядке, что в PDF** (от новых к старым, сверху вниз).
---
## Шаг 3. Валидация перед записью
### 3.1 Проверка баланса
Вычислить:
```
income_sum = сумма всех amountSigned > 0 (в копейках)
expense_sum = сумма всех amountSigned < 0 (в копейках, отрицательная)
```
Проверить, что:
```
openingBalance + income_sum + expense_sum == closingBalance
```
**Если не совпадает** — это нормально и объясняется поведением ВТБ:
> ВТБ формирует итоги в шапке по **дате обработки банком**, а не по дате операции.
> Операции с датой обработки **вне периода выписки** включаются в список транзакций, но **не учитываются в строках «Поступления» и «Расходные операции»** в шапке.
Правильная проверка:
1. Определить границы периода из шапки PDF (атаачала`, атаонца`)
2. Из `Поступления` и `Расходные операции` вычислить ожидаемый net:
`expected_net = openingBalance_kopecks + income_pdf_kopecks - expense_pdf_kopecks`
3. Убедиться, что `expected_net == closingBalance` (это всегда верно если данные из PDF прочитаны правильно)
4. Разница между суммой всех транзакций и `(closingBalance - openingBalance)` — это сумма операций, обработанных банком вне периода. Это **не ошибка**.
### 3.2 Проверка полноты
- Подсчитать количество извлечённых транзакций
- Пройтись по всем страницам PDF и убедиться, что ни одна строка таблицы не пропущена
- Особое внимание: операции в конце страницы и в начале следующей (частые точки потери данных)
### 3.3 Проверка типов
Убедиться, что:
- `openingBalance`, `closingBalance` — целые числа (`int`)
- `amountSigned`, `commission` — целые числа (`int`)
- `operationAt`, `exportedAt` — строки в формате ISO 8601 с `+03:00`
- `description` — строка (не `null`, не пустая)
---
## Шаг 4. Частые ошибки — предотвращение
| Ошибка | Причина | Как избежать |
|--------|---------|--------------|
| Сумма в рублях вместо копеек | Забыли умножить на 100 | Всегда применять `round(x * 100)` |
| Дробные числа вместо int | Потеря точности float | Использовать `round()`, результат кастовать в `int` |
| Неверный знак `amountSigned` | Ориентировались на знак в PDF | Определять знак по столбцу «Приход»/«Расход» |
| Потеря операций на стыке страниц | Парсинг постраничный | Читать PDF целиком, не разбивать по страницам |
| Обрезанное описание | Перенос строки в ячейке PDF | Объединять строки одной ячейки в одну строку |
| Комиссия со знаком минус | Перепутали знак | Комиссия всегда `>= 0` |
| `amountSigned = 0` без условия | Импорт отклонит файл | Разрешать `amountSigned = 0` только при `commission > 0` и наличии «Зачисление» в `description` |
| Неверный `exportedAt` | Взяли дату начала периода | Брать дату и время **первой (самой новой) операции** в таблице |
| Расхождение баланса | Не учли операции вне периода | Это не ошибка — см. Шаг 3.1 |
| Лишние пробелы в описании | PDF добавляет пробелы при переносе | Применять `.strip()` и нормализацию пробелов |
---
## Шаг 5. Запись файла
- Имя файла: то же, что у исходного PDF, с заменой расширения `.pdf``.json`
Пример: `file-18.pdf``file-18.json`
- Кодировка: **UTF-8**
- Формат: JSON с отступами (indent = 2)
- `ensure_ascii = false` — кириллица записывается как есть, не экранируется
---
## Пример корректной транзакции
```json
{
"operationAt": "2026-02-26T14:06:00+03:00",
"amountSigned": -50000,
"operationAt": "2026-03-29T20:38:01+03:00",
"amountSigned": -500000,
"commission": 0,
"description": "Оплата товаров. PAVELETSKAYA по карте *8214"
"description": "Оплата товаров и услуг. IP KOVTUN L.B.. по карте *9058"
}
```
Обработай прикреплённый PDF и верни один JSON-объект.
Расшифровка: списание 5 000.00 ₽ = -500 000 копеек, комиссии нет.
---
## Пример корректного поступления
```json
{
"operationAt": "2026-03-24T15:09:41+03:00",
"amountSigned": 9537522,
"commission": 0,
"description": "Поступление заработной платы. 0726 НАЦИОНАЛЬНЫЙ ИССЛЕДОВАТЕЛЬСКИЙ УНИВЕРСИТЕТ \"ВЫСША Поступление заработной платы/иных выплат Salary по реестру Z_0000005862_20260323_001_01 от 23.03.2026. Без НДС.."
}
```
Расшифровка: поступление 95 375.22 ₽ = +9 537 522 копейки.
---
## Пример корректного кэшбэка (сумма в commission)
```json
{
"operationAt": "2026-04-07T19:56:59+03:00",
"amountSigned": 0,
"commission": 592000,
"description": "Зачисление кешбэка по программе лояльности."
}
```
Расшифровка: для такого кейса `amountSigned = 0` допустим, потому что есть `commission > 0` и маркер «Зачисление» в описании.
---
## Итоговый чеклист перед отдачей файла
- [ ] `schemaVersion` = `"1.0"`
- [ ] `bank` = `"VTB"`
- [ ] `accountNumber` совпадает с PDF
- [ ] `openingBalance` и `closingBalance` в копейках, совпадают с PDF
- [ ] `exportedAt` = дата и время первой операции в таблице, формат ISO 8601 +03:00
- [ ] Количество транзакций совпадает с количеством строк в PDF
- [ ] Все `amountSigned` и `commission` — целые числа
- [ ] Если `amountSigned = 0`, то `commission > 0` и в `description` есть «Зачисление» (без учёта регистра)
- [ ] Поступления и баланс проверены (Шаг 3.1)
- [ ] Файл сохранён в UTF-8, кириллица не экранирована

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/shared",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -18,6 +18,8 @@ export interface AnalyticsSummaryResponse {
totalExpense: number;
totalIncome: number;
net: number;
investmentOutflow: number;
investmentIncomeExcluded: number;
topCategories: TopCategory[];
}
@@ -50,4 +52,5 @@ export interface TimeseriesItem {
periodEnd: string;
expenseAmount: number;
incomeAmount: number;
investmentOutflow: number;
}

View File

@@ -5,4 +5,5 @@ export interface LoginRequest {
export interface MeResponse {
login: string;
backendVersion: string;
}

View File

@@ -1,3 +1,16 @@
/** Import record from imports table (for import history) */
export interface Import {
id: number;
importedAt: string;
accountId: number | null;
accountAlias: string | null;
bank: string;
accountNumberMasked: string;
importedCount: number;
duplicatesSkipped: number;
totalInFile: number;
}
/** JSON 1.0 statement file — the shape accepted by POST /api/import/statement */
export interface StatementFile {
schemaVersion: '1.0';

View File

@@ -37,6 +37,7 @@ export type {
StatementHeader,
StatementTransaction,
ImportStatementResponse,
Import,
} from './import';
export type {