Compare commits
33 Commits
fix/sse-ev
...
feature/br
| Author | SHA1 | Date | |
|---|---|---|---|
| 3af9e9dbfe | |||
| b99555ca23 | |||
| 5c059cbc62 | |||
| 3d08c9e5c6 | |||
| 076ab7b055 | |||
| e78445888f | |||
| 36562f8d78 | |||
| 1cbef453b0 | |||
| 019714a8cf | |||
| a79629a60d | |||
| ea70be8e5a | |||
| 2c98d9c534 | |||
| 2422ca44b0 | |||
| 6610e43200 | |||
|
|
ec62a0591e | ||
| 97b61de092 | |||
|
|
0e0186fdbb | ||
| 02ca34d088 | |||
|
|
f6d3196254 | ||
|
|
fccde4259d | ||
| e97203c4ab | |||
|
|
6dd8c01f5e | ||
| 495c1e89bb | |||
|
|
032a9f4e3b | ||
| d5f49fd86f | |||
|
|
ab88a0553d | ||
| 0589da5005 | |||
| c50e48d564 | |||
|
|
01b1f26553 | ||
|
|
ba3105bbe5 | ||
| f32a21f87a | |||
|
|
8b57dd987e | ||
| ea234ea007 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -23,3 +23,5 @@ history.xlsx
|
||||
match_analysis.py
|
||||
match_report.txt
|
||||
statements/
|
||||
temp/
|
||||
.cursor/
|
||||
|
||||
40
CHANGELOG.md
Normal file
40
CHANGELOG.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Changelog
|
||||
|
||||
## [Backend 0.6.6] - 2026-08-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Re-importing an existing operation UUID now safely skips the row even if other source fields changed.
|
||||
|
||||
## [Backend 0.6.5] - 2026-08-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Duplicate operation UUIDs in one import file now return a validation error instead of a database failure.
|
||||
|
||||
## [Frontend 0.9.2 / Backend 0.6.4 / Shared 0.2.2] - 2026-08-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added deterministic UUIDs for imported operations and a unique database constraint per account.
|
||||
|
||||
## [Frontend 0.9.1 / Backend 0.6.3 / Shared 0.2.1] - 2026-08-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added a standard-library VTB broker XLSX converter for cash movements and a portfolio/trades sidecar JSON.
|
||||
- Added optional source operation IDs so repeated statement imports remain idempotent without collapsing legitimate identical operations.
|
||||
|
||||
## [Frontend 0.9.0 / Backend 0.6.2] - 2026-08-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Analytics now calculate net income and expenses by category type: refunds reduce the original expense category and internal transfers no longer affect P&L.
|
||||
- Added a separate cash-flow summary with incoming, outgoing, and transfer amounts.
|
||||
- Added unit and transactional SQL checks for analytics calculations.
|
||||
|
||||
## [0.8.7] - 2026-08-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Completed the frontend CSS class migration to BEM naming and aligned component styles with the design system.
|
||||
18
README.md
18
README.md
@@ -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.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/backend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/app.ts",
|
||||
@@ -8,6 +8,8 @@
|
||||
"start": "node dist/app.js",
|
||||
"migrate": "tsx src/db/migrate.ts",
|
||||
"migrate:prod": "node dist/db/migrate.js",
|
||||
"test:analytics": "tsx src/services/analyticsSemantics.test.ts",
|
||||
"test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts",
|
||||
"test:llm": "tsx src/scripts/testLlm.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -133,6 +133,34 @@ const migrations: { name: string; sql: string }[] = [
|
||||
AND NOT EXISTS (SELECT 1 FROM category_rules LIMIT 1);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: '006_transaction_operation_uuid',
|
||||
sql: `
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS operation_id UUID NOT NULL DEFAULT gen_random_uuid();
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_transactions_account_operation_id
|
||||
ON transactions(account_id, operation_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
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 +204,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> {
|
||||
|
||||
@@ -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,68 +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.socket?.setNoDelay(true);
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
36
backend/src/routes/imports.ts
Normal file
36
backend/src/routes/imports.ts
Normal 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;
|
||||
42
backend/src/services/analytics.integration.test.ts
Normal file
42
backend/src/services/analytics.integration.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { pool } from '../db/pool';
|
||||
import { getByCategory, getSummary, getTimeseries } from './analytics';
|
||||
|
||||
async function testQueries(): Promise<void> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const account = await client.query("INSERT INTO accounts (bank, account_number, currency) VALUES ('TEST', 'analytics-test-1', 'RUB') RETURNING id");
|
||||
const otherAccount = await client.query("INSERT INTO accounts (bank, account_number, currency) VALUES ('TEST', 'analytics-test-2', 'RUB') RETURNING id");
|
||||
const categories = await client.query("INSERT INTO categories (name, type) VALUES ('Тест расход', 'expense'), ('Тест доход', 'income'), ('Тест перевод', 'transfer') RETURNING id, type");
|
||||
const accountId = Number(account.rows[0].id);
|
||||
const otherAccountId = Number(otherAccount.rows[0].id);
|
||||
const categoryId = Object.fromEntries(categories.rows.map((row) => [row.type, Number(row.id)]));
|
||||
const insert = (account: number, at: string, amount: number, category: number, fingerprint: string) => client.query(
|
||||
"INSERT INTO transactions (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) VALUES ($1, $2, $3, 0, 'test', CASE WHEN $3 < 0 THEN 'expense' ELSE 'income' END, $4, $5, TRUE)",
|
||||
[account, at, amount, fingerprint, category],
|
||||
);
|
||||
|
||||
await insert(accountId, '2026-07-01T12:00:00+03:00', -10_000, categoryId.expense, 'analytics-test-1');
|
||||
await insert(accountId, '2026-07-02T12:00:00+03:00', 4_000, categoryId.expense, 'analytics-test-2');
|
||||
await insert(accountId, '2026-07-03T12:00:00+03:00', 20_000, categoryId.income, 'analytics-test-3');
|
||||
await insert(accountId, '2026-07-04T12:00:00+03:00', -5_000, categoryId.income, 'analytics-test-4');
|
||||
await insert(accountId, '2026-07-05T12:00:00+03:00', -3_000, categoryId.transfer, 'analytics-test-5');
|
||||
await insert(otherAccountId, '2026-07-01T12:00:00+03:00', -99_000, categoryId.expense, 'analytics-test-6');
|
||||
await insert(accountId, '2026-08-01T12:00:00+03:00', -7_000, categoryId.expense, 'analytics-test-7');
|
||||
|
||||
const params = { from: '2026-07-01', to: '2026-07-31', accountId, onlyConfirmed: true };
|
||||
const summary = await getSummary(params, client);
|
||||
assert.deepEqual({ expense: summary.totalExpense, income: summary.totalIncome, net: summary.net, transferOut: summary.transferOutflow }, { expense: 6_000, income: 15_000, net: 9_000, transferOut: 3_000 });
|
||||
assert.equal((await getSummary({ ...params, from: '2026-08-01', to: '2026-08-31' }, client)).totalExpense, 7_000);
|
||||
assert.deepEqual((await getByCategory(params, client)).map((item) => item.amount), [6_000]);
|
||||
const timeseries = await getTimeseries({ ...params, granularity: 'month' }, client);
|
||||
assert.equal(timeseries[0].expenseAmount, 6_000);
|
||||
assert.equal(timeseries[0].incomeAmount, 15_000);
|
||||
} finally {
|
||||
await client.query('ROLLBACK');
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
testQueries().then(() => console.log('analytics SQL: OK')).finally(() => pool.end());
|
||||
@@ -1,4 +1,5 @@
|
||||
import { pool } from '../db/pool';
|
||||
import { effectiveAmountCase } from './analyticsSemantics';
|
||||
import type {
|
||||
AnalyticsSummaryResponse,
|
||||
TopCategory,
|
||||
@@ -7,6 +8,8 @@ import type {
|
||||
Granularity,
|
||||
} from '@family-budget/shared';
|
||||
|
||||
type Queryable = Pick<typeof pool, 'query'>;
|
||||
|
||||
interface BaseParams {
|
||||
from: string;
|
||||
to: string;
|
||||
@@ -14,6 +17,24 @@ interface BaseParams {
|
||||
onlyConfirmed?: boolean;
|
||||
}
|
||||
|
||||
const effectiveAmount = effectiveAmountCase('t');
|
||||
|
||||
function analyticsTransactions(where: string): string {
|
||||
return `
|
||||
WITH analytics_transactions AS (
|
||||
SELECT
|
||||
t.id,
|
||||
t.operation_at,
|
||||
COALESCE(c.type, t.direction) AS analytic_type,
|
||||
COALESCE(t.category_id, 0) AS category_id,
|
||||
COALESCE(c.name, 'Без категории') AS category_name,
|
||||
${effectiveAmount} AS effective_amount
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
${where}
|
||||
)`;
|
||||
}
|
||||
|
||||
function buildBaseConditions(
|
||||
params: BaseParams,
|
||||
startIdx: number,
|
||||
@@ -42,31 +63,45 @@ function buildBaseConditions(
|
||||
return { conditions, values, nextIdx: idx };
|
||||
}
|
||||
|
||||
export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryResponse> {
|
||||
export async function getSummary(
|
||||
params: BaseParams,
|
||||
db: Queryable = pool,
|
||||
): Promise<AnalyticsSummaryResponse> {
|
||||
const { conditions, values } = buildBaseConditions(params, 1);
|
||||
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
|
||||
FROM transactions t
|
||||
${where}`,
|
||||
const totalsResult = await db.query(
|
||||
`${analyticsTransactions(where)},
|
||||
category_net AS (
|
||||
SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount
|
||||
FROM analytics_transactions
|
||||
GROUP BY category_id, category_name, analytic_type
|
||||
)
|
||||
SELECT
|
||||
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'expense'), 0)::bigint AS total_expense,
|
||||
COALESCE(SUM(GREATEST(amount, 0)) FILTER (WHERE analytic_type = 'income'), 0)::bigint AS total_income,
|
||||
COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_inflow,
|
||||
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_outflow,
|
||||
COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_inflow,
|
||||
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow
|
||||
FROM category_net`,
|
||||
values,
|
||||
);
|
||||
|
||||
const totalExpense = Number(totalsResult.rows[0].total_expense);
|
||||
const totalIncome = Number(totalsResult.rows[0].total_income);
|
||||
const cashInflow = Number(totalsResult.rows[0].cash_inflow);
|
||||
const cashOutflow = Number(totalsResult.rows[0].cash_outflow);
|
||||
const transferInflow = Number(totalsResult.rows[0].transfer_inflow);
|
||||
const transferOutflow = Number(totalsResult.rows[0].transfer_outflow);
|
||||
|
||||
const topResult = await pool.query(
|
||||
`SELECT
|
||||
t.category_id,
|
||||
c.name AS category_name,
|
||||
SUM(ABS(t.amount_signed))::bigint AS amount
|
||||
FROM transactions t
|
||||
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
|
||||
const topResult = await db.query(
|
||||
`${analyticsTransactions(where)}
|
||||
SELECT category_id::bigint, category_name, GREATEST(-SUM(effective_amount), 0)::bigint AS amount
|
||||
FROM analytics_transactions
|
||||
WHERE analytic_type = 'expense'
|
||||
GROUP BY category_id, category_name
|
||||
HAVING SUM(effective_amount) < 0
|
||||
ORDER BY amount DESC
|
||||
LIMIT 5`,
|
||||
values,
|
||||
@@ -83,36 +118,39 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
|
||||
totalExpense,
|
||||
totalIncome,
|
||||
net: totalIncome - totalExpense,
|
||||
cashInflow,
|
||||
cashOutflow,
|
||||
transferInflow,
|
||||
transferOutflow,
|
||||
cashNet: cashInflow - cashOutflow,
|
||||
topCategories,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getByCategory(params: BaseParams): Promise<ByCategoryItem[]> {
|
||||
export async function getByCategory(
|
||||
params: BaseParams,
|
||||
db: Queryable = pool,
|
||||
): Promise<ByCategoryItem[]> {
|
||||
const { conditions, values } = buildBaseConditions(params, 1);
|
||||
const where = 'WHERE ' + conditions.join(' AND ');
|
||||
|
||||
const totalResult = await pool.query(
|
||||
`SELECT COALESCE(SUM(ABS(t.amount_signed)), 0)::bigint AS total
|
||||
FROM transactions t
|
||||
${where} AND t.direction = 'expense'`,
|
||||
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,
|
||||
const { rows } = await db.query(
|
||||
`${analyticsTransactions(where)}
|
||||
SELECT
|
||||
category_id::bigint,
|
||||
category_name,
|
||||
GREATEST(-SUM(effective_amount), 0)::bigint AS amount,
|
||||
COUNT(*)::int AS tx_count
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
${where} AND t.direction = 'expense'
|
||||
GROUP BY t.category_id, c.name
|
||||
FROM analytics_transactions
|
||||
WHERE analytic_type = 'expense'
|
||||
GROUP BY category_id, category_name
|
||||
HAVING SUM(effective_amount) < 0
|
||||
ORDER BY amount DESC`,
|
||||
values,
|
||||
);
|
||||
|
||||
const total = rows.reduce((sum, row) => sum + Number(row.amount), 0);
|
||||
|
||||
return rows.map((r) => ({
|
||||
categoryId: r.category_id != null ? Number(r.category_id) : 0,
|
||||
categoryName: r.category_name ?? 'Без категории',
|
||||
@@ -124,6 +162,7 @@ export async function getByCategory(params: BaseParams): Promise<ByCategoryItem[
|
||||
|
||||
export async function getTimeseries(
|
||||
params: BaseParams & { categoryId?: number; granularity: Granularity },
|
||||
db: Queryable = pool,
|
||||
): Promise<TimeseriesItem[]> {
|
||||
let truncExpr: string;
|
||||
let intervalStr: string;
|
||||
@@ -168,22 +207,38 @@ export async function getTimeseries(
|
||||
|
||||
const txWhere = txConditions.join(' AND ');
|
||||
|
||||
const { rows } = await pool.query(
|
||||
const { rows } = await db.query(
|
||||
`WITH periods AS (
|
||||
SELECT
|
||||
gs::date AS period_start,
|
||||
${periodEndExpr} AS period_end
|
||||
FROM generate_series(${truncExpr}, $2::date, '${intervalStr}'::interval) gs
|
||||
),
|
||||
period_transactions AS (
|
||||
SELECT
|
||||
p.period_start,
|
||||
p.period_end,
|
||||
COALESCE(c.type, t.direction) AS analytic_type,
|
||||
COALESCE(t.category_id, 0) AS category_id,
|
||||
${effectiveAmount} AS effective_amount
|
||||
FROM periods p
|
||||
LEFT JOIN transactions t ON ${txWhere}
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
),
|
||||
period_category_net AS (
|
||||
SELECT period_start, period_end, analytic_type, category_id, SUM(effective_amount)::bigint AS amount
|
||||
FROM period_transactions
|
||||
GROUP BY period_start, period_end, analytic_type, 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
|
||||
FROM periods p
|
||||
LEFT JOIN transactions t ON ${txWhere}
|
||||
GROUP BY p.period_start, p.period_end
|
||||
ORDER BY p.period_start`,
|
||||
period_start,
|
||||
period_end,
|
||||
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'expense'), 0)::bigint AS expense_amount,
|
||||
COALESCE(SUM(GREATEST(amount, 0)) FILTER (WHERE analytic_type = 'income'), 0)::bigint AS income_amount,
|
||||
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow
|
||||
FROM period_category_net
|
||||
GROUP BY period_start, period_end
|
||||
ORDER BY period_start`,
|
||||
values,
|
||||
);
|
||||
|
||||
@@ -192,5 +247,6 @@ export async function getTimeseries(
|
||||
periodEnd: r.period_end.toISOString().slice(0, 10),
|
||||
expenseAmount: Number(r.expense_amount),
|
||||
incomeAmount: Number(r.income_amount),
|
||||
transferOutflow: Number(r.transfer_outflow),
|
||||
}));
|
||||
}
|
||||
|
||||
25
backend/src/services/analyticsSemantics.test.ts
Normal file
25
backend/src/services/analyticsSemantics.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { getEffectiveAmount, getNetAmount } from './analyticsSemantics';
|
||||
|
||||
assert.equal(getNetAmount('expense', [
|
||||
{ amountSigned: -10_000, commission: 0 },
|
||||
{ amountSigned: 4_000, commission: 0 },
|
||||
]), 6_000, 'возврат уменьшает расход своей категории');
|
||||
|
||||
assert.equal(getNetAmount('income', [
|
||||
{ amountSigned: 20_000, commission: 0 },
|
||||
{ amountSigned: -5_000, commission: 0 },
|
||||
]), 15_000, 'корректировка уменьшает доход');
|
||||
|
||||
assert.equal(getNetAmount('transfer', [
|
||||
{ amountSigned: -10_000, commission: 0 },
|
||||
{ amountSigned: 10_000, commission: 0 },
|
||||
]), 0, 'перевод не является доходом или расходом');
|
||||
|
||||
assert.equal(
|
||||
getEffectiveAmount({ amountSigned: 0, commission: 500, description: 'Зачисление кэшбэка' }),
|
||||
500,
|
||||
'кэшбэк учитывается как поступление',
|
||||
);
|
||||
|
||||
console.log('analytics semantics: OK');
|
||||
32
backend/src/services/analyticsSemantics.ts
Normal file
32
backend/src/services/analyticsSemantics.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type AnalyticType = 'expense' | 'income' | 'transfer';
|
||||
|
||||
interface AmountInput {
|
||||
amountSigned: number;
|
||||
commission: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function getEffectiveAmount({ amountSigned, commission, description = '' }: AmountInput): number {
|
||||
if (amountSigned === 0 && commission > 0 && description.toLowerCase().includes('зачисление')) {
|
||||
return commission;
|
||||
}
|
||||
return amountSigned < 0 ? amountSigned - commission : amountSigned + commission;
|
||||
}
|
||||
|
||||
export function getNetAmount(type: AnalyticType, amounts: AmountInput[]): number {
|
||||
const amount = amounts.reduce((sum, item) => sum + getEffectiveAmount(item), 0);
|
||||
if (type === 'expense') return Math.max(-amount, 0);
|
||||
if (type === 'income') return Math.max(amount, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function effectiveAmountCase(alias: string): string {
|
||||
return `
|
||||
CASE
|
||||
WHEN ${alias}.amount_signed = 0 AND ${alias}.commission > 0
|
||||
AND ${alias}.description ILIKE '%зачисление%'
|
||||
THEN ${alias}.commission
|
||||
WHEN ${alias}.amount_signed < 0 THEN ${alias}.amount_signed - ${alias}.commission
|
||||
ELSE ${alias}.amount_signed + ${alias}.commission
|
||||
END`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,11 +8,18 @@ const TRANSFER_PHRASES = [
|
||||
'перевод средств на счет',
|
||||
'внутри втб',
|
||||
];
|
||||
const CASHBACK_KEYWORD = 'зачисление';
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function computeFingerprint(
|
||||
accountNumber: string,
|
||||
tx: { operationAt: string; amountSigned: number; commission: number; description: string },
|
||||
tx: { operationAt: string; amountSigned: number; commission: number; description: string; sourceId?: string },
|
||||
): string {
|
||||
if (tx.sourceId) {
|
||||
const raw = [accountNumber, tx.sourceId.trim()].join('|');
|
||||
const hash = crypto.createHash('sha256').update(raw, 'utf-8').digest('hex');
|
||||
return `sha256:${hash}`;
|
||||
}
|
||||
const raw = [
|
||||
accountNumber,
|
||||
tx.operationAt,
|
||||
@@ -84,15 +91,31 @@ 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 (t.sourceId !== undefined && (typeof t.sourceId !== 'string' || !t.sourceId.trim())) {
|
||||
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].sourceId must be a non-empty string when provided` };
|
||||
}
|
||||
if (t.operationId !== undefined && (typeof t.operationId !== 'string' || !UUID_RE.test(t.operationId))) {
|
||||
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].operationId must be a valid UUID when provided` };
|
||||
}
|
||||
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;
|
||||
@@ -110,12 +133,20 @@ function validateSemantics(data: StatementFile): ValidationError | null {
|
||||
}
|
||||
|
||||
const fps = new Set<string>();
|
||||
const operationIds = new Set<string>();
|
||||
for (let i = 0; i < data.transactions.length; i++) {
|
||||
const fp = computeFingerprint(data.statement.accountNumber, data.transactions[i]);
|
||||
if (fps.has(fp)) {
|
||||
return { status: 422, error: 'VALIDATION_ERROR', message: `Duplicate fingerprint found within file at transaction index ${i}` };
|
||||
}
|
||||
fps.add(fp);
|
||||
const operationId = data.transactions[i].operationId;
|
||||
if (operationId) {
|
||||
if (operationIds.has(operationId.toLowerCase())) {
|
||||
return { status: 422, error: 'VALIDATION_ERROR', message: `Duplicate operationId found within file at transaction index ${i}` };
|
||||
}
|
||||
operationIds.add(operationId.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -155,20 +186,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)
|
||||
ON CONFLICT (account_id, fingerprint) DO NOTHING
|
||||
(account_id, operation_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id)
|
||||
VALUES ($1, COALESCE($2::uuid, gen_random_uuid()), $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id`,
|
||||
[accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp],
|
||||
[accountId, tx.operationId ?? null, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, categoryId, isCategoryConfirmed, importId],
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
@@ -176,6 +236,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 +275,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,
|
||||
|
||||
44
backend/src/services/imports.ts
Normal file
44
backend/src/services/imports.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,128 +132,31 @@ export async function convertPdfToStatement(
|
||||
};
|
||||
}
|
||||
|
||||
return parseConversionResult(content);
|
||||
} catch (err) {
|
||||
console.error('LLM conversion error:', err);
|
||||
return {
|
||||
status: 502,
|
||||
error: 'BAD_GATEWAY',
|
||||
message: extractLlmErrorMessage(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type ProgressStage = 'pdf' | 'llm' | 'import';
|
||||
export type OnProgress = (stage: ProgressStage, progress: number, message: string) => void;
|
||||
|
||||
const LLM_PROGRESS_MIN = 10;
|
||||
const LLM_PROGRESS_MAX = 98;
|
||||
const LLM_PROGRESS_RANGE = LLM_PROGRESS_MAX - LLM_PROGRESS_MIN;
|
||||
const THROTTLE_MS = 300;
|
||||
|
||||
function yieldToEventLoop(): Promise<void> {
|
||||
return new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
onProgress('pdf', 2, 'Извлечение текста из PDF...');
|
||||
await yieldToEventLoop();
|
||||
|
||||
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...');
|
||||
await yieldToEventLoop();
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
const expectedChars = Math.max(2_000, Math.min(text.length * 2, 30_000));
|
||||
|
||||
let accumulated = '';
|
||||
let charsReceived = 0;
|
||||
let lastEmitTime = 0;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta?.content;
|
||||
if (delta) {
|
||||
accumulated += delta;
|
||||
charsReceived += delta.length;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastEmitTime >= THROTTLE_MS) {
|
||||
const ratio = Math.min(1, charsReceived / expectedChars);
|
||||
const llmProgress = Math.min(
|
||||
LLM_PROGRESS_MAX,
|
||||
Math.round(ratio * LLM_PROGRESS_RANGE + LLM_PROGRESS_MIN),
|
||||
);
|
||||
onProgress('llm', llmProgress, 'Конвертация через LLM...');
|
||||
lastEmitTime = now;
|
||||
// Let the event loop flush socket writes to the network
|
||||
await yieldToEventLoop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onProgress('llm', LLM_PROGRESS_MAX, 'LLM завершил, обработка результата...');
|
||||
await yieldToEventLoop();
|
||||
|
||||
const content = accumulated.trim();
|
||||
if (!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: 'Результат конвертации пуст',
|
||||
message: 'Результат конвертации не является валидным JSON',
|
||||
};
|
||||
}
|
||||
|
||||
return parseConversionResult(content);
|
||||
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 streaming error:', err);
|
||||
console.error('LLM conversion error:', err);
|
||||
return {
|
||||
status: 502,
|
||||
error: 'BAD_GATEWAY',
|
||||
@@ -274,29 +178,3 @@ function extractLlmErrorMessage(err: unknown): string {
|
||||
}
|
||||
return 'Временная ошибка конвертации';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -37,6 +37,26 @@
|
||||
- Чистый результат (`net = totalIncome - totalExpense`).
|
||||
- Топ-3–5 категорий по расходам (сумма, доля).
|
||||
|
||||
#### Нетто-правила
|
||||
|
||||
Тип назначенной категории определяет экономический смысл операции:
|
||||
|
||||
- `expense`: отрицательная сумма увеличивает расход, положительная сумма
|
||||
(возврат) уменьшает его; в отчёте категория не может стать отрицательным
|
||||
расходом;
|
||||
- `income`: положительная сумма увеличивает доход, отрицательная уменьшает
|
||||
его;
|
||||
- `transfer`: не учитывается в реальных доходах и расходах.
|
||||
|
||||
Если возврат не удалось определить правилом, пользователь вручную назначает
|
||||
ему ту же расходную категорию, что у исходной покупки. Отдельная категория
|
||||
«Возврат» и связывание двух операций не требуются.
|
||||
|
||||
Для контроля остатков сводка отдельно показывает движение денежных средств:
|
||||
входящий и исходящий поток, внутренние переводы и чистое изменение денег.
|
||||
Переводы между картой, накопительными и инвестиционными счетами остаются в
|
||||
этом блоке, но не искажают доходы и расходы.
|
||||
|
||||
Реализуется через эндпоинт `GET /api/analytics/summary`.
|
||||
|
||||
Параметры:
|
||||
@@ -52,6 +72,11 @@
|
||||
"totalExpense": 12345600,
|
||||
"totalIncome": 20000000,
|
||||
"net": 7654400,
|
||||
"cashInflow": 26000000,
|
||||
"cashOutflow": 18345600,
|
||||
"transferInflow": 5000000,
|
||||
"transferOutflow": 5000000,
|
||||
"cashNet": 7654400,
|
||||
"topCategories": [
|
||||
{ "categoryId": 1, "categoryName": "Продукты", "amount": 4500000, "share": 0.36 },
|
||||
{ "categoryId": 2, "categoryName": "ЖКХ", "amount": 2500000, "share": 0.20 }
|
||||
|
||||
@@ -141,6 +141,12 @@ accountNumber|operationAt|amountSigned|commission|normalizedDescription
|
||||
|
||||
Список ключевых фраз для `"transfer"` может расширяться; в MVP используется фиксированный набор.
|
||||
|
||||
`direction` — исходная банковская классификация. В аналитике окончательный
|
||||
экономический тип определяется типом назначенной категории: операция,
|
||||
отнесённая к расходной категории, является расходом или возвратом независимо
|
||||
от исходного `direction`; категория типа `transfer` исключает операцию из
|
||||
реальных доходов и расходов.
|
||||
|
||||
### Импорт транзакций
|
||||
|
||||
Для каждой транзакции из массива `transactions`:
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
gzip off;
|
||||
client_max_body_size 15m;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.9.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal 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">₽</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 271 B |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
5
frontend/src/api/version.ts
Normal file
5
frontend/src/api/version.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { api } from './client';
|
||||
|
||||
export async function getBackendVersion(): Promise<{ version: string }> {
|
||||
return api.get<{ version: string }>('/api/version');
|
||||
}
|
||||
@@ -36,28 +36,28 @@ export function AccountsList() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="section-loading">Загрузка...</div>;
|
||||
return <div className="state state--loading">Загрузка...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Банк</th>
|
||||
<th>Номер счёта</th>
|
||||
<th>Валюта</th>
|
||||
<th>Алиас</th>
|
||||
<th></th>
|
||||
<thead className="data-table__head">
|
||||
<tr className="data-table__row">
|
||||
<th className="data-table__head-cell">Банк</th>
|
||||
<th className="data-table__head-cell">Номер счёта</th>
|
||||
<th className="data-table__head-cell">Валюта</th>
|
||||
<th className="data-table__head-cell">Алиас</th>
|
||||
<th className="data-table__head-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="data-table__body">
|
||||
{accounts.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td>{a.bank}</td>
|
||||
<td>{a.accountNumberMasked}</td>
|
||||
<td>{a.currency}</td>
|
||||
<td>
|
||||
<tr className="data-table__row" key={a.id}>
|
||||
<td className="data-table__cell">{a.bank}</td>
|
||||
<td className="data-table__cell">{a.accountNumberMasked}</td>
|
||||
<td className="data-table__cell">{a.currency}</td>
|
||||
<td className="data-table__cell">
|
||||
{editingId === a.id ? (
|
||||
<input
|
||||
type="text"
|
||||
@@ -72,21 +72,21 @@ export function AccountsList() {
|
||||
/>
|
||||
) : (
|
||||
a.alias || (
|
||||
<span className="text-muted">не задан</span>
|
||||
<span className="text text--muted">не задан</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<td className="data-table__cell">
|
||||
{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)}
|
||||
>
|
||||
Изменить
|
||||
@@ -104,8 +104,8 @@ export function AccountsList() {
|
||||
</tr>
|
||||
))}
|
||||
{accounts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="td-center text-muted">
|
||||
<tr className="data-table__row">
|
||||
<td colSpan={5} className="data-table__cell data-table__cell--center text text--muted">
|
||||
Нет счетов. Импортируйте выписку.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -21,24 +21,24 @@ export function CategoriesList() {
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="section-loading">Загрузка...</div>;
|
||||
return <div className="state state--loading">Загрузка...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Категория</th>
|
||||
<th>Тип</th>
|
||||
<thead className="data-table__head">
|
||||
<tr className="data-table__row">
|
||||
<th className="data-table__head-cell">Категория</th>
|
||||
<th className="data-table__head-cell">Тип</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="data-table__body">
|
||||
{categories.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>{c.name}</td>
|
||||
<td>
|
||||
<span className={`badge badge-${c.type}`}>
|
||||
<tr className="data-table__row" key={c.id}>
|
||||
<td className="data-table__cell">{c.name}</td>
|
||||
<td className="data-table__cell">
|
||||
<span className={`badge badge--${c.type}`}>
|
||||
{TYPE_LABELS[c.type] ?? c.type}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -14,9 +14,14 @@ interface Props {
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
|
||||
'#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1',
|
||||
'#14b8a6', '#e11d48', '#0ea5e9', '#a855f7', '#22c55e',
|
||||
'var(--color-chart-1)', 'var(--color-chart-2)',
|
||||
'var(--color-chart-3)', 'var(--color-chart-4)',
|
||||
'var(--color-chart-5)', 'var(--color-chart-6)',
|
||||
'var(--color-chart-7)', 'var(--color-chart-8)',
|
||||
'var(--color-chart-9)', 'var(--color-chart-10)',
|
||||
'var(--color-chart-11)', 'var(--color-chart-12)',
|
||||
'var(--color-chart-13)', 'var(--color-chart-14)',
|
||||
'var(--color-chart-15)',
|
||||
];
|
||||
|
||||
const rubFormatter = new Intl.NumberFormat('ru-RU', {
|
||||
@@ -30,7 +35,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 +46,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 +63,7 @@ export function CategoryChart({ data }: Props) {
|
||||
>
|
||||
{chartData.map((_, idx) => (
|
||||
<Cell
|
||||
key={idx}
|
||||
key={`${idx}-${COLORS[idx % COLORS.length]}`}
|
||||
fill={COLORS[idx % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
@@ -69,21 +74,21 @@ export function CategoryChart({ data }: Props) {
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
<table className="category-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Категория</th>
|
||||
<th>Сумма</th>
|
||||
<th className="th-center">Операций</th>
|
||||
<th className="th-center">Доля</th>
|
||||
<table className="category-chart__table">
|
||||
<thead className="category-chart__head">
|
||||
<tr className="category-chart__row">
|
||||
<th className="category-chart__head-cell">Категория</th>
|
||||
<th className="category-chart__head-cell">Сумма</th>
|
||||
<th className="category-chart__head-cell category-chart__head-cell--center">Операций</th>
|
||||
<th className="category-chart__head-cell category-chart__head-cell--center">Доля</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="category-chart__body">
|
||||
{data.map((item, idx) => (
|
||||
<tr key={item.categoryId}>
|
||||
<td>
|
||||
<tr className="category-chart__row" key={item.categoryId}>
|
||||
<td className="category-chart__cell">
|
||||
<span
|
||||
className="color-dot"
|
||||
className="category-chart__dot"
|
||||
style={{
|
||||
backgroundColor:
|
||||
COLORS[idx % COLORS.length],
|
||||
@@ -91,9 +96,9 @@ 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">{formatAmount(item.amount)}</td>
|
||||
<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>
|
||||
|
||||
@@ -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,25 +31,30 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Очистить историю операций</h2>
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
<div
|
||||
className="modal"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="modal__dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal__header">
|
||||
<h2 className="modal__title">Очистить историю операций</h2>
|
||||
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||
×
|
||||
</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">
|
||||
<label>
|
||||
<div className="field field--checkbox field--flush">
|
||||
<label className="field__label field__label--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={check1}
|
||||
@@ -65,22 +64,8 @@ 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">
|
||||
<label>
|
||||
<div className="field field--checkbox field--flush">
|
||||
<label className="field__label field__label--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={check2}
|
||||
@@ -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 modal__action"
|
||||
onClick={handleConfirm}
|
||||
disabled={!canConfirm || loading}
|
||||
>
|
||||
{loading ? 'Удаление…' : 'Удалить всё'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onClose}>
|
||||
<button className="button button--secondary modal__action" onClick={onClose}>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
<h3>Очистка данных</h3>
|
||||
<p className="section-desc">
|
||||
<div className="data-section__block">
|
||||
<h3 className="data-section__title">История импортов</h3>
|
||||
<p className="data-section__description">
|
||||
Список импортов выписок. Можно удалить операции конкретного импорта.
|
||||
</p>
|
||||
{imports.length === 0 ? (
|
||||
<p className="text text--muted">Импортов пока нет.</p>
|
||||
) : (
|
||||
<div className="table-shell">
|
||||
<table className="data-table">
|
||||
<thead className="data-table__head">
|
||||
<tr className="data-table__row">
|
||||
<th className="data-table__head-cell">Дата</th>
|
||||
<th className="data-table__head-cell">Счёт</th>
|
||||
<th className="data-table__head-cell">Банк</th>
|
||||
<th className="data-table__head-cell">Импортировано</th>
|
||||
<th className="data-table__head-cell">Дубликаты</th>
|
||||
<th className="data-table__head-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="data-table__body">
|
||||
{imports.map((imp) => (
|
||||
<tr className="data-table__row" key={imp.id}>
|
||||
<td className="data-table__cell">{formatDate(imp.importedAt)}</td>
|
||||
<td className="data-table__cell">
|
||||
{imp.accountAlias || imp.accountNumberMasked || '—'}
|
||||
</td>
|
||||
<td className="data-table__cell">{imp.bank}</td>
|
||||
<td className="data-table__cell">{imp.importedCount}</td>
|
||||
<td className="data-table__cell">{imp.duplicatesSkipped}</td>
|
||||
<td className="data-table__cell">
|
||||
<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 className="data-section__title">Очистка данных</h3>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
75
frontend/src/components/DeleteImportModal.tsx
Normal file
75
frontend/src/components/DeleteImportModal.tsx
Normal 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"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="modal__dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal__header">
|
||||
<h2 className="modal__title">Удалить импорт</h2>
|
||||
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||
×
|
||||
</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 modal__action"
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Удаление…' : 'Удалить'}
|
||||
</button>
|
||||
<button type="button" className="button button--secondary modal__action" onClick={onClose}>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,38 +82,49 @@ export function EditTransactionModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Редактирование операции</h2>
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
<div
|
||||
className="modal"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="modal__dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal__header">
|
||||
<h2 className="modal__title">Редактирование операции</h2>
|
||||
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<form className="form" onSubmit={handleSubmit}>
|
||||
<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">
|
||||
<label htmlFor="edit-category">Категория</label>
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor="edit-category">Категория</label>
|
||||
<select
|
||||
id="edit-category"
|
||||
value={categoryId}
|
||||
@@ -120,8 +139,8 @@ export function EditTransactionModal({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-comment">Комментарий</label>
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor="edit-comment">Комментарий</label>
|
||||
<textarea
|
||||
id="edit-comment"
|
||||
rows={2}
|
||||
@@ -131,10 +150,10 @@ export function EditTransactionModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-divider" />
|
||||
<div className="form__divider" />
|
||||
|
||||
<div className="form-group form-group-checkbox">
|
||||
<label>
|
||||
<div className="field field--checkbox">
|
||||
<label className="field__label field__label--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createRule}
|
||||
@@ -146,8 +165,8 @@ export function EditTransactionModal({
|
||||
|
||||
{createRule && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-pattern">
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor="edit-pattern">
|
||||
Шаблон (ключевая строка)
|
||||
</label>
|
||||
<input
|
||||
@@ -158,8 +177,8 @@ export function EditTransactionModal({
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group form-group-checkbox">
|
||||
<label>
|
||||
<div className="field field--checkbox">
|
||||
<label className="field__label field__label--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={requiresConfirmation}
|
||||
@@ -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 modal__action"
|
||||
onClick={onClose}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
className="button button--primary modal__action"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
|
||||
@@ -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,45 +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 {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
onDone();
|
||||
};
|
||||
|
||||
const handleSaveAlias = async () => {
|
||||
if (!result || !alias.trim()) return;
|
||||
try {
|
||||
@@ -84,94 +58,75 @@ 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" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Импорт выписки</h2>
|
||||
<button className="btn-close" onClick={handleClose}>
|
||||
<div
|
||||
className="modal"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="modal__dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal__header">
|
||||
<h2 className="modal__title">Импорт выписки</h2>
|
||||
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||
×
|
||||
</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>
|
||||
<p className="import-upload__description">
|
||||
Выберите файл выписки (PDF или JSON, формат 1.0)
|
||||
</p>
|
||||
<input
|
||||
ref={fileRef}
|
||||
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>
|
||||
<h3>Импорт завершён</h3>
|
||||
<table className="import-stats">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Счёт</td>
|
||||
<td>{result.accountNumberMasked}</td>
|
||||
<div className="import-result__icon" aria-hidden="true">✓</div>
|
||||
<h3 className="import-result__title">Импорт завершён</h3>
|
||||
<table className="import-result__stats">
|
||||
<tbody className="import-result__stats-body">
|
||||
<tr className="import-result__stat-row">
|
||||
<td className="import-result__stat-cell import-result__stat-cell--label">Счёт</td>
|
||||
<td className="import-result__stat-cell import-result__stat-cell--value">{result.accountNumberMasked}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Новый счёт</td>
|
||||
<td>{result.isNewAccount ? 'Да' : 'Нет'}</td>
|
||||
<tr className="import-result__stat-row">
|
||||
<td className="import-result__stat-cell import-result__stat-cell--label">Новый счёт</td>
|
||||
<td className="import-result__stat-cell import-result__stat-cell--value">{result.isNewAccount ? 'Да' : 'Нет'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Импортировано</td>
|
||||
<td>{result.imported}</td>
|
||||
<tr className="import-result__stat-row">
|
||||
<td className="import-result__stat-cell import-result__stat-cell--label">Импортировано</td>
|
||||
<td className="import-result__stat-cell import-result__stat-cell--value">{result.imported}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Дубликатов пропущено</td>
|
||||
<td>{result.duplicatesSkipped}</td>
|
||||
<tr className="import-result__stat-row">
|
||||
<td className="import-result__stat-cell import-result__stat-cell--label">Дубликатов пропущено</td>
|
||||
<td className="import-result__stat-cell import-result__stat-cell--value">{result.duplicatesSkipped}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Всего в файле</td>
|
||||
<td>{result.totalInFile}</td>
|
||||
<tr className="import-result__stat-row">
|
||||
<td className="import-result__stat-cell import-result__stat-cell--label">Всего в файле</td>
|
||||
<td className="import-result__stat-cell import-result__stat-cell--value">{result.totalInFile}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{result.isNewAccount && !aliasSaved && (
|
||||
<div className="import-alias">
|
||||
<label>Алиас для нового счёта</label>
|
||||
<div className="import-alias-row">
|
||||
<div className="import-result__alias">
|
||||
<label className="import-result__alias-label">Алиас для нового счёта</label>
|
||||
<div className="import-result__alias-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Напр.: Текущий, Накопительный"
|
||||
@@ -180,7 +135,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()}
|
||||
>
|
||||
@@ -191,7 +146,7 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
)}
|
||||
|
||||
{aliasSaved && (
|
||||
<div className="import-alias-saved">
|
||||
<div className="import-result__alias-saved">
|
||||
Алиас сохранён
|
||||
</div>
|
||||
)}
|
||||
@@ -199,17 +154,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 modal__action" onClick={onDone}>
|
||||
Готово
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleClose}
|
||||
className="button button--secondary modal__action"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
{importState.active ? 'Свернуть' : 'Отмена'}
|
||||
Отмена
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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, openModal } = 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);
|
||||
openModal();
|
||||
setVisible(false);
|
||||
}, [openModal]);
|
||||
|
||||
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,27 +33,27 @@ 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}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg className="sidebar__nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
@@ -124,11 +66,11 @@ 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}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg className="sidebar__nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10" />
|
||||
<line x1="12" y1="20" x2="12" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="14" />
|
||||
@@ -139,11 +81,11 @@ 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}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg className="sidebar__nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
|
||||
</svg>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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="Предыдущая страница"
|
||||
>
|
||||
←
|
||||
</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="Следующая страница"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
|
||||
@@ -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="Предыдущий период"
|
||||
>
|
||||
←
|
||||
</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">—</span>
|
||||
<span className="period-picker__separator">—</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="Следующий период"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -51,56 +51,59 @@ export function RulesList() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="section-loading">Загрузка...</div>;
|
||||
return <div className="state state--loading">Загрузка...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<div className="rules-list settings-section">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Шаблон</th>
|
||||
<th>Категория</th>
|
||||
<th className="th-center">Приоритет</th>
|
||||
<th className="th-center">Подтверждение</th>
|
||||
<th>Создано</th>
|
||||
<th className="th-center">Активно</th>
|
||||
<th></th>
|
||||
<thead className="data-table__head">
|
||||
<tr className="data-table__row">
|
||||
<th className="data-table__head-cell">Шаблон</th>
|
||||
<th className="data-table__head-cell">Категория</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 className="data-table__head-cell">Создано</th>
|
||||
<th className="data-table__head-cell data-table__head-cell--center">Активно</th>
|
||||
<th className="data-table__head-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="data-table__body">
|
||||
{rules.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className={!r.isActive ? 'row-inactive' : ''}
|
||||
className={`data-table__row ${!r.isActive ? 'data-table__row--inactive' : ''}`}
|
||||
>
|
||||
<td>
|
||||
<td className="data-table__cell">
|
||||
<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">{r.categoryName}</td>
|
||||
<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">
|
||||
<td className="data-table__cell">
|
||||
<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>
|
||||
)}
|
||||
@@ -117,8 +120,8 @@ export function RulesList() {
|
||||
</tr>
|
||||
))}
|
||||
{rules.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="td-center text-muted">
|
||||
<tr className="data-table__row">
|
||||
<td colSpan={7} className="data-table__cell data-table__cell--center text text--muted">
|
||||
Нет правил
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -6,47 +6,63 @@ 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.cashNet)}
|
||||
</div>
|
||||
<div className="summary__subvalue">Поступило: {formatAmount(summary.cashInflow)}</div>
|
||||
<div className="summary__subvalue">Списано: {formatAmount(summary.cashOutflow)}</div>
|
||||
{(summary.transferInflow > 0 || summary.transferOutflow > 0) && (
|
||||
<div className="summary__subvalue">
|
||||
Переводы: {formatAmount(summary.transferInflow)} / {formatAmount(summary.transferOutflow)}
|
||||
</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>
|
||||
|
||||
@@ -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,
|
||||
Переводы: item.transferOutflow / 100,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" 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="var(--color-text-secondary)"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v: number) =>
|
||||
v >= 1000 ? `${(v / 1000).toFixed(0)}к` : String(v)
|
||||
}
|
||||
fontSize={12}
|
||||
stroke="#64748b"
|
||||
stroke="var(--color-text-secondary)"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => rubFormatter.format(value)}
|
||||
cursor={{ fill: 'var(--color-chart-cursor)' }}
|
||||
contentStyle={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8,
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="Расходы"
|
||||
fill="#ef4444"
|
||||
fill="var(--color-danger)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="Доходы"
|
||||
fill="#10b981"
|
||||
fill="var(--color-success)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="Переводы"
|
||||
fill="var(--color-warning)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
|
||||
@@ -115,28 +115,29 @@ export function TransactionFilters({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="filters-panel">
|
||||
<div className="filters-row">
|
||||
<div className="filter-group">
|
||||
<label>Период</label>
|
||||
<div className="filter-dates-wrap">
|
||||
<div className="filters">
|
||||
<div className="filters__row">
|
||||
<div className="field field--period">
|
||||
<label className="field__label">Период</label>
|
||||
<div className="filters__date-control">
|
||||
{filters.periodMode !== 'custom' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-page"
|
||||
className="icon-button"
|
||||
onClick={() => navigate(-1)}
|
||||
aria-label="Предыдущий период"
|
||||
title="Предыдущий период"
|
||||
>
|
||||
←
|
||||
</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">—</span>
|
||||
<span className="filters__separator">—</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="Следующий период"
|
||||
>
|
||||
→
|
||||
</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,8 +178,8 @@ export function TransactionFilters({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Счёт</label>
|
||||
<div className="field">
|
||||
<label className="field__label">Счёт</label>
|
||||
<select
|
||||
value={filters.accountId}
|
||||
onChange={(e) => set('accountId', e.target.value)}
|
||||
@@ -191,8 +193,8 @@ export function TransactionFilters({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Тип</label>
|
||||
<div className="field">
|
||||
<label className="field__label">Тип</label>
|
||||
<select
|
||||
value={filters.direction}
|
||||
onChange={(e) => set('direction', e.target.value)}
|
||||
@@ -204,8 +206,8 @@ export function TransactionFilters({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Категория</label>
|
||||
<div className="field">
|
||||
<label className="field__label">Категория</label>
|
||||
<select
|
||||
value={filters.categoryId}
|
||||
onChange={(e) => set('categoryId', e.target.value)}
|
||||
@@ -220,9 +222,9 @@ export function TransactionFilters({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filters-row">
|
||||
<div className="filter-group filter-group-wide">
|
||||
<label>Поиск</label>
|
||||
<div className="filters__row">
|
||||
<div className="field field--wide">
|
||||
<label className="field__label">Поиск</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по описанию..."
|
||||
@@ -231,8 +233,8 @@ export function TransactionFilters({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Сумма от (₽)</label>
|
||||
<div className="field">
|
||||
<label className="field__label">Сумма от (₽)</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="мин"
|
||||
@@ -241,8 +243,8 @@ export function TransactionFilters({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Сумма до (₽)</label>
|
||||
<div className="field">
|
||||
<label className="field__label">Сумма до (₽)</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="макс"
|
||||
@@ -251,8 +253,8 @@ export function TransactionFilters({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-group filter-group-checkbox">
|
||||
<label>
|
||||
<div className="field field--checkbox">
|
||||
<label className="field__label field__label--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.onlyUnconfirmed}
|
||||
@@ -262,9 +264,9 @@ export function TransactionFilters({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Сортировка</label>
|
||||
<div className="filter-sort">
|
||||
<div className="field">
|
||||
<label className="field__label">Сортировка</label>
|
||||
<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>
|
||||
|
||||
@@ -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,76 +91,82 @@ 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>
|
||||
<th>Дата</th>
|
||||
<th>Счёт</th>
|
||||
<th>Сумма</th>
|
||||
<th>Описание</th>
|
||||
<th>Категория</th>
|
||||
<th className="th-center">Статус</th>
|
||||
<th></th>
|
||||
<thead className="data-table__head">
|
||||
<tr className="data-table__row">
|
||||
<th className="data-table__head-cell">Дата</th>
|
||||
<th className="data-table__head-cell">Счёт</th>
|
||||
<th className="data-table__head-cell">Сумма</th>
|
||||
<th className="data-table__head-cell">Описание</th>
|
||||
<th className="data-table__head-cell">Категория</th>
|
||||
<th className="data-table__head-cell data-table__head-cell--center">Статус</th>
|
||||
<th className="data-table__head-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="data-table__body">
|
||||
{transactions.map((tx) => (
|
||||
<tr
|
||||
key={tx.id}
|
||||
className={
|
||||
!tx.isCategoryConfirmed && tx.categoryId
|
||||
? 'row-unconfirmed'
|
||||
: ''
|
||||
? 'data-table__row data-table__row--unconfirmed'
|
||||
: 'data-table__row'
|
||||
}
|
||||
>
|
||||
<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="text text--muted">—</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="Категория не подтверждена"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<td className="data-table__cell">
|
||||
<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} />
|
||||
))}
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -1,114 +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;
|
||||
showModal: boolean;
|
||||
openModal: () => void;
|
||||
closeModal: () => void;
|
||||
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 [showModal, setShowModal] = useState(false);
|
||||
const runningRef = useRef(false);
|
||||
|
||||
const openModal = useCallback(() => setShowModal(true), []);
|
||||
const closeModal = useCallback(() => setShowModal(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, showModal, openModal, closeModal, 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;
|
||||
}
|
||||
@@ -88,15 +88,18 @@ 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">
|
||||
<label>Счёт</label>
|
||||
<div className="analytics-panel__filters">
|
||||
<div className="field">
|
||||
<label className="field__label">Счёт</label>
|
||||
<select
|
||||
value={accountId}
|
||||
onChange={(e) => setAccountId(e.target.value)}
|
||||
@@ -109,8 +112,8 @@ export function AnalyticsPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="filter-group filter-group-checkbox">
|
||||
<label>
|
||||
<div className="field field--checkbox">
|
||||
<label className="field__label field__label--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyConfirmed}
|
||||
@@ -123,17 +126,17 @@ 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>
|
||||
<h3 className="chart-card__title">Динамика</h3>
|
||||
<TimeseriesChart data={timeseries} />
|
||||
</div>
|
||||
<div className="chart-card">
|
||||
<h3>По категориям</h3>
|
||||
<h3 className="chart-card__title">По категориям</h3>
|
||||
<CategoryChart data={byCategory} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,6 @@ import { TransactionTable } from '../components/TransactionTable';
|
||||
import { Pagination } from '../components/Pagination';
|
||||
import { EditTransactionModal } from '../components/EditTransactionModal';
|
||||
import { ImportModal } from '../components/ImportModal';
|
||||
import { useImport } from '../context/ImportContext';
|
||||
import { toISODate } from '../utils/format';
|
||||
|
||||
const PARAM_KEYS = [
|
||||
@@ -126,7 +125,7 @@ export function HistoryPage() {
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [editingTx, setEditingTx] = useState<Transaction | null>(null);
|
||||
const { showModal: showImport, openModal: openImport, closeModal: closeImport, clearImport } = useImport();
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getAccounts().then(setAccounts).catch(() => {});
|
||||
@@ -198,20 +197,27 @@ export function HistoryPage() {
|
||||
};
|
||||
|
||||
const handleImportDone = () => {
|
||||
closeImport();
|
||||
clearImport();
|
||||
setShowImport(false);
|
||||
fetchData();
|
||||
getAccounts().then(setAccounts).catch(() => {});
|
||||
};
|
||||
|
||||
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"
|
||||
onClick={openImport}
|
||||
className="button button--primary page__action"
|
||||
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>
|
||||
@@ -264,7 +270,7 @@ export function HistoryPage() {
|
||||
|
||||
{showImport && (
|
||||
<ImportModal
|
||||
onClose={closeImport}
|
||||
onClose={() => setShowImport(false)}
|
||||
onDone={handleImportDone}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -20,17 +20,17 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<span className="login-icon">₽</span>
|
||||
<h1>Семейный бюджет</h1>
|
||||
<p>Войдите для продолжения</p>
|
||||
<div className="login">
|
||||
<div className="login__panel">
|
||||
<div className="login__header">
|
||||
<span className="login__icon">₽</span>
|
||||
<h1 className="login__title">Семейный бюджет</h1>
|
||||
<p className="login__subtitle">Войдите для продолжения</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<div className="form-group">
|
||||
<label htmlFor="login">Логин</label>
|
||||
<form onSubmit={handleSubmit} className="form login__form">
|
||||
{error && <div className="alert alert--error">{error}</div>}
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor="login">Логин</label>
|
||||
<input
|
||||
id="login"
|
||||
type="text"
|
||||
@@ -41,8 +41,8 @@ export function LoginPage() {
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Пароль</label>
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor="password">Пароль</label>
|
||||
<input
|
||||
id="password"
|
||||
type="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 ? 'Вход...' : 'Войти'}
|
||||
|
||||
@@ -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
2
frontend/src/vite-env.d.ts
vendored
2
frontend/src/vite-env.d.ts
vendored
@@ -1 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __FE_VERSION__: string;
|
||||
|
||||
@@ -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
11
package-lock.json
generated
@@ -64,7 +64,7 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.9.0",
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "family-budget",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"shared",
|
||||
|
||||
232
pdf2json.md
232
pdf2json.md
@@ -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, кириллица не экранирована
|
||||
|
||||
210
scripts/convert_vtb_broker_xlsx.py
Normal file
210
scripts/convert_vtb_broker_xlsx.py
Normal file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert a VTB broker XLSX report to the import JSON formats.
|
||||
|
||||
Only the cash-movement section is sent to the existing statement importer.
|
||||
Securities are written to a sidecar file for the future portfolio importer.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
NS = {'m': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
||||
EPOCH = datetime(1899, 12, 30)
|
||||
COLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
OPERATION_NAMESPACE = uuid.UUID('7a4a1c7d-7d3c-4f53-9f45-5e7dbb2c6f31')
|
||||
|
||||
|
||||
def excel_date(value):
|
||||
if value in (None, ''):
|
||||
return None
|
||||
number = float(value)
|
||||
date = EPOCH + timedelta(days=number)
|
||||
return date.isoformat(timespec='seconds') + '+03:00'
|
||||
|
||||
|
||||
def excel_day(value):
|
||||
return excel_date(value)[:10] if excel_date(value) else None
|
||||
|
||||
|
||||
def kopecks(value):
|
||||
if value in (None, ''):
|
||||
return 0
|
||||
return int((Decimal(str(value)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)) * 100)
|
||||
|
||||
|
||||
def text(value):
|
||||
return re.sub(r'\s+', ' ', str(value or '')).strip()
|
||||
|
||||
|
||||
def read_rows(path):
|
||||
with ZipFile(path) as book:
|
||||
shared_root = ET.fromstring(book.read('xl/sharedStrings.xml'))
|
||||
shared = [text(''.join(node.itertext())) for node in shared_root.findall('m:si', NS)]
|
||||
root = ET.fromstring(book.read('xl/worksheets/sheet1.xml'))
|
||||
rows = []
|
||||
for row in root.findall('.//m:sheetData/m:row', NS):
|
||||
cells = {}
|
||||
for cell in row.findall('m:c', NS):
|
||||
ref = cell.attrib.get('r', '')
|
||||
col = re.match(r'[A-Z]+', ref).group(0)
|
||||
value = cell.find('m:v', NS)
|
||||
raw = value.text if value is not None else ''
|
||||
if cell.attrib.get('t') == 's' and raw:
|
||||
raw = shared[int(raw)]
|
||||
cells[col] = raw
|
||||
rows.append((int(row.attrib['r']), cells))
|
||||
return rows
|
||||
|
||||
|
||||
def row_text(cells):
|
||||
return text(' '.join(str(value) for value in cells.values()))
|
||||
|
||||
|
||||
def find_row(rows, phrase, start=0):
|
||||
for index in range(start, len(rows)):
|
||||
if phrase.lower() in row_text(rows[index][1]).lower():
|
||||
return index
|
||||
raise ValueError(f'Не найден раздел: {phrase}')
|
||||
|
||||
|
||||
def metadata(rows):
|
||||
account = None
|
||||
period = re.search(r'период с (\d{2}\.\d{2}\.\d{4}) по (\d{2}\.\d{2}\.\d{4})', row_text(dict(rows)))
|
||||
report_date = None
|
||||
for _, cells in rows[:35]:
|
||||
joined = row_text(cells)
|
||||
match = re.search(r'(\d{20})\s*\(RUR\)', joined)
|
||||
account = account or (match.group(1) if match else None)
|
||||
if 'Дата формирования отчета' in joined:
|
||||
for value in cells.values():
|
||||
if value and re.fullmatch(r'\d+(?:\.\d+)?', str(value)):
|
||||
report_date = excel_day(value)
|
||||
if not account or not period:
|
||||
raise ValueError('Не удалось определить счёт или период отчёта')
|
||||
return account, period.groups(), report_date
|
||||
|
||||
|
||||
def cash_transactions(rows, start, end):
|
||||
transactions = []
|
||||
occurrences = defaultdict(int)
|
||||
for number, cells in rows[start + 1:end]:
|
||||
if not cells.get('B') or not cells.get('C') or not cells.get('J'):
|
||||
continue
|
||||
try:
|
||||
operation_at = excel_date(cells['B'])
|
||||
amount = kopecks(cells['C'])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
operation = text(cells.get('J'))
|
||||
comment = text(cells.get('P'))
|
||||
description = text(f'{operation}. {comment}'.strip('. '))
|
||||
digest = hashlib.sha256(f'{operation_at}|{amount}|{description}'.encode()).hexdigest()[:16]
|
||||
occurrences[digest] += 1
|
||||
source_id = f'vtb-broker-cash:{digest}:{occurrences[digest]}'
|
||||
transactions.append({
|
||||
'operationAt': operation_at,
|
||||
'amountSigned': amount,
|
||||
'commission': 0,
|
||||
'description': description,
|
||||
'sourceId': source_id,
|
||||
'operationId': str(uuid.uuid5(OPERATION_NAMESPACE, source_id)),
|
||||
'_sourceRow': number,
|
||||
})
|
||||
if not transactions:
|
||||
raise ValueError('Операции движения денежных средств не найдены')
|
||||
return transactions
|
||||
|
||||
|
||||
def portfolio(rows, holdings_start, movement_start, trades_start, trades_end, account, period, report_date):
|
||||
positions = []
|
||||
for number, cells in rows[holdings_start + 1:movement_start]:
|
||||
instrument = text(cells.get('B'))
|
||||
if not instrument or instrument.lower().startswith('итого') or not re.search(r'RU[A-Z0-9]{10}', instrument):
|
||||
continue
|
||||
positions.append({
|
||||
'sourceRow': number,
|
||||
'instrument': instrument,
|
||||
'isin': next((part for part in instrument.split(', ') if re.fullmatch(r'RU[A-Z0-9]{10}', part)), None),
|
||||
'quantity': cells.get('L') or cells.get('M') or cells.get('I') or cells.get('J'),
|
||||
'price': cells.get('P'),
|
||||
'valuation': cells.get('AF') or cells.get('AJ'),
|
||||
})
|
||||
trades = []
|
||||
columns = {'instrument': 'B', 'concludedAt': 'C', 'side': 'F', 'quantity': 'H',
|
||||
'priceCurrency': 'I', 'price': 'J', 'settlementCurrency': 'L',
|
||||
'settlementAmount': 'M', 'nkd': 'O', 'settlementCommission': 'P',
|
||||
'tradeCommission': 'R', 'plannedDeliveryDate': 'S', 'plannedPaymentDate': 'T',
|
||||
'orderId': 'W', 'tradeId': 'Z', 'organizerTradeId': 'AC', 'venue': 'AK',
|
||||
'comment': 'AN'}
|
||||
for number, cells in rows[trades_start + 1:trades_end]:
|
||||
if not cells.get('B') or not cells.get('C') or not cells.get('F'):
|
||||
continue
|
||||
try:
|
||||
concluded_at = excel_date(cells['C'])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
item = {key: text(cells.get(col)) for key, col in columns.items()}
|
||||
item['sourceRow'] = number
|
||||
item['concludedAt'] = concluded_at
|
||||
item['sourceId'] = f"vtb-broker-trade:{item['tradeId'] or number}"
|
||||
item['operationId'] = str(uuid.uuid5(OPERATION_NAMESPACE, item['sourceId']))
|
||||
trades.append(item)
|
||||
return {
|
||||
'schemaVersion': 'broker-portfolio-1.0',
|
||||
'bank': 'VTB_BROKER',
|
||||
'accountNumber': account,
|
||||
'reportPeriod': {'from': datetime.strptime(period[0], '%d.%m.%Y').date().isoformat(),
|
||||
'to': datetime.strptime(period[1], '%d.%m.%Y').date().isoformat()},
|
||||
'reportedAt': report_date + 'T00:00:00+03:00' if report_date else None,
|
||||
'positions': positions,
|
||||
'trades': trades,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) not in (3, 4):
|
||||
raise SystemExit('usage: convert_vtb_broker_xlsx.py INPUT_XLSX CASH_JSON [PORTFOLIO_JSON]')
|
||||
input_path, cash_path = map(Path, sys.argv[1:3])
|
||||
portfolio_path = Path(sys.argv[3]) if len(sys.argv) == 4 else None
|
||||
rows = read_rows(input_path)
|
||||
account, period, report_date = metadata(rows)
|
||||
holdings_start = find_row(rows, 'Отчёт об остатках ценных бумаг')
|
||||
movement_start = find_row(rows, 'Движение ценных бумаг', holdings_start)
|
||||
cash_start = find_row(rows, 'Движение денежных средств')
|
||||
trades_start = find_row(rows, 'Заключенные в отчетном периоде сделки с ценными бумагами')
|
||||
trades_end = find_row(rows, 'Завершенные в отчетном периоде сделки с ценными бумагами', trades_start + 1)
|
||||
transactions = cash_transactions(rows, cash_start, holdings_start)
|
||||
assert len({item['sourceId'] for item in transactions}) == len(transactions), 'sourceId операции не уникальны'
|
||||
opening = kopecks(rows[find_row(rows, 'Отчёт об остатках денежных средств') + 3][1].get('L'))
|
||||
balance_row = rows[find_row(rows, 'Отчёт об остатках денежных средств') + 3][1]
|
||||
closing = kopecks(balance_row.get('AF'))
|
||||
cash = {'schemaVersion': '1.0', 'bank': 'VTB_BROKER',
|
||||
'statement': {'accountNumber': account, 'currency': 'RUB',
|
||||
'openingBalance': opening, 'closingBalance': closing,
|
||||
'exportedAt': (report_date or transactions[-1]['operationAt'][:10]) + 'T00:00:00+03:00'},
|
||||
'transactions': [{key: value for key, value in item.items() if not key.startswith('_')}
|
||||
for item in transactions]}
|
||||
calculated_closing = opening + sum(item['amountSigned'] for item in transactions)
|
||||
if calculated_closing != closing:
|
||||
raise ValueError(f'Баланс не сходится: операции дают {calculated_closing}, XLSX содержит {closing}')
|
||||
cash_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cash_path.write_text(json.dumps(cash, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
duplicate_candidates = len(transactions) - len({item['sourceId'].rsplit(':', 1)[0] for item in transactions})
|
||||
if portfolio_path:
|
||||
portfolio_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
portfolio_path.write_text(json.dumps(portfolio(rows, holdings_start, movement_start, trades_start, trades_end, account, period, report_date), ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
print(json.dumps({'transactions': len(transactions), 'cashNet': sum(item['amountSigned'] for item in transactions),
|
||||
'openingBalance': opening, 'closingBalance': closing, 'duplicateCandidates': duplicate_candidates,
|
||||
'cashJson': str(cash_path), 'portfolioJson': str(portfolio_path) if portfolio_path else None}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/shared",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.2",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -18,6 +18,11 @@ export interface AnalyticsSummaryResponse {
|
||||
totalExpense: number;
|
||||
totalIncome: number;
|
||||
net: number;
|
||||
cashInflow: number;
|
||||
cashOutflow: number;
|
||||
transferInflow: number;
|
||||
transferOutflow: number;
|
||||
cashNet: number;
|
||||
topCategories: TopCategory[];
|
||||
}
|
||||
|
||||
@@ -50,4 +55,5 @@ export interface TimeseriesItem {
|
||||
periodEnd: string;
|
||||
expenseAmount: number;
|
||||
incomeAmount: number;
|
||||
transferOutflow: number;
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ export interface LoginRequest {
|
||||
|
||||
export interface MeResponse {
|
||||
login: string;
|
||||
backendVersion: string;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -19,6 +32,10 @@ export interface StatementTransaction {
|
||||
amountSigned: number;
|
||||
commission: number;
|
||||
description: string;
|
||||
/** Stable source-row identity used to make repeated imports idempotent. */
|
||||
sourceId?: string;
|
||||
/** Stable operation UUID emitted by source-specific converters. */
|
||||
operationId?: string;
|
||||
}
|
||||
|
||||
export interface ImportStatementResponse {
|
||||
|
||||
@@ -37,6 +37,7 @@ export type {
|
||||
StatementHeader,
|
||||
StatementTransaction,
|
||||
ImportStatementResponse,
|
||||
Import,
|
||||
} from './import';
|
||||
|
||||
export type {
|
||||
|
||||
Reference in New Issue
Block a user