Compare commits

...

44 Commits

Author SHA1 Message Date
36562f8d78 docs: update analytics changelog 2026-08-19 09:14:57 +03:00
1cbef453b0 test(analytics): verify SQL aggregates 2026-08-19 09:12:43 +03:00
019714a8cf test(analytics): cover net category semantics 2026-08-19 07:49:54 +03:00
a79629a60d feat(analytics): add net cashflow reporting 2026-08-18 23:09:40 +03:00
ea70be8e5a Merge pull request 'refactor(frontend): complete BEM design system' (#20) from agent/family_budget:refactor/complete-bem-design-system into main
Reviewed-on: #20
2026-08-16 06:29:58 +00:00
2c98d9c534 docs: add frontend 0.8.7 changelog 2026-08-16 09:19:46 +03:00
2422ca44b0 refactor(frontend): complete BEM design system 2026-08-14 23:58:14 +03:00
6610e43200 Merge pull request 'feat(frontend): refresh UI with warm fintech redesign' (#19) from feature/redesign into main
Reviewed-on: #19
2026-04-22 08:41:37 +00:00
Anton
ec62a0591e feat(frontend): refresh UI with warm fintech redesign 2026-04-22 11:40:15 +03:00
97b61de092 Merge pull request 'fix: close modal popups only on overlay mousedown' (#18) from fix/modal-mousedown-overlay-close into main
Reviewed-on: #18
2026-04-15 14:02:52 +00:00
Anton
0e0186fdbb fix: close modal popups only on overlay mousedown 2026-04-15 17:02:15 +03:00
02ca34d088 Merge pull request 'feat(analytics): account commission and investment transfers' (#17) from feat/commission-analytics-investments into main
Reviewed-on: #17
2026-04-15 11:32:56 +00:00
Anton
f6d3196254 merge main into feat/commission-analytics-investments
Made-with: Cursor
2026-04-15 14:30:29 +03:00
Anton
fccde4259d feat(analytics): account commission and investment transfers
Handle cashback commission imports, include commissions in analytics with separate investment metrics, and expose commission/version details in the UI.

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

2
.gitignore vendored
View File

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

15
CHANGELOG.md Normal file
View File

@@ -0,0 +1,15 @@
# Changelog
## [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.

View File

@@ -27,13 +27,10 @@ FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# @napi-rs/canvas (dep of pdf-parse) ships a musl pre-built binary that
# needs these compatibility / font libraries at runtime.
RUN apk add --no-cache libc6-compat fontconfig freetype
COPY --from=build /app/backend/dist ./dist
COPY --from=build /app/backend/package.json ./package.json
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/backend/node_modules/ ./node_modules/
COPY backend/.env .env
EXPOSE 3000

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/backend",
"version": "0.1.0",
"version": "0.6.2",
"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": {
@@ -18,7 +20,7 @@
"express": "^5.2.1",
"multer": "^2.1.1",
"openai": "^6.27.0",
"pdf-parse": "^2.4.5",
"pdf-parse": "1.1.1",
"pg": "^8.19.0",
"uuid": "^13.0.0"
},

View File

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

View File

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

View File

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

View File

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

View File

@@ -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),
}));
}

View 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');

View 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`;
}

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ const PDF2JSON_PROMPT = `Ты — конвертер банковских вып
"transactions": [
{
"operationAt": "<дата и время операции в формате ISO 8601 с offset>",
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>,
"amountSigned": <число: положительное для прихода, отрицательное для расхода; 0 допустим только если сумма фактически в commission и в description есть «Зачисление»>,
"commission": <число, целое, >= 0, в копейках>,
"description": "<полное описание операции из выписки>"
}
@@ -30,6 +30,7 @@ const PDF2JSON_PROMPT = `Ты — конвертер банковских вып
1. Суммы — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456.
2. amountSigned: приход — положительное, расход — отрицательное.
amountSigned = 0 допускается только для кейса кэшбэка/зачисления, когда сумма указана в commission и в description есть «Зачисление».
3. operationAt — дата и время, если не указано — 00:00:00, offset +03:00 для МСК.
4. commission — если не указана — 0.
5. description — полный текст операции как в выписке.
@@ -63,16 +64,14 @@ export function isPdfConversionError(r: unknown): r is PdfConversionError {
);
}
// Lazy-loaded to avoid crashing the app at startup — pdf-parse pulls in
// @napi-rs/canvas (native Skia binary) which may fail on Alpine.
let _PDFParse: typeof import('pdf-parse')['PDFParse'] | undefined;
function loadPDFParse() {
if (!_PDFParse) {
// Lazy-loaded so the app starts even if pdf-parse is missing from node_modules.
let _pdfParse: ((buf: Buffer) => Promise<{ text: string }>) | undefined;
function getPdfParse() {
if (!_pdfParse) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require('pdf-parse') as typeof import('pdf-parse');
_PDFParse = mod.PDFParse;
_pdfParse = require('pdf-parse') as (buf: Buffer) => Promise<{ text: string }>;
}
return _PDFParse;
return _pdfParse;
}
export async function convertPdfToStatement(
@@ -88,11 +87,8 @@ export async function convertPdfToStatement(
let text: string;
try {
const PDFParse = loadPDFParse();
const parser = new PDFParse({ data: buffer });
const result = await parser.getText();
const result = await getPdfParse()(buffer);
text = result.text || '';
await parser.destroy();
} catch (err) {
console.error('PDF extraction error:', err);
return {
@@ -113,6 +109,7 @@ export async function convertPdfToStatement(
const openai = new OpenAI({
apiKey: config.llmApiKey,
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
timeout: 5 * 60 * 1000,
});
try {
@@ -124,7 +121,6 @@ export async function convertPdfToStatement(
],
temperature: 0,
max_tokens: 32768,
response_format: { type: 'json_object' },
});
const content = completion.choices[0]?.message?.content?.trim();
@@ -164,7 +160,21 @@ export async function convertPdfToStatement(
return {
status: 502,
error: 'BAD_GATEWAY',
message: 'Временная ошибка конвертации',
message: extractLlmErrorMessage(err),
};
}
}
function extractLlmErrorMessage(err: unknown): string {
const raw = String(
(err as Record<string, unknown>)?.message ??
(err as Record<string, Record<string, unknown>>)?.error?.message ?? '',
);
if (/context.length|n_ctx|too.many.tokens|maximum.context/i.test(raw)) {
return 'PDF-файл слишком большой для обработки. Попробуйте файл с меньшим количеством операций или используйте модель с большим контекстным окном.';
}
if (/timeout|timed?\s*out|ETIMEDOUT|ECONNREFUSED/i.test(raw)) {
return 'LLM-сервер не отвечает. Проверьте, что сервер запущен и доступен.';
}
return 'Временная ошибка конвертации';
}

View File

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

View File

@@ -37,6 +37,26 @@
- Чистый результат (`net = totalIncome - totalExpense`).
- Топ-35 категорий по расходам (сумма, доля).
#### Нетто-правила
Тип назначенной категории определяет экономический смысл операции:
- `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 }

View File

@@ -141,6 +141,12 @@ accountNumber|operationAt|amountSigned|commission|normalizedDescription
Список ключевых фраз для `"transfer"` может расширяться; в MVP используется фиксированный набор.
`direction` — исходная банковская классификация. В аналитике окончательный
экономический тип определяется типом назначенной категории: операция,
отнесённая к расходной категории, является расходом или возвратом независимо
от исходного `direction`; категория типа `transfer` исключает операцию из
реальных доходов и расходов.
### Импорт транзакций
Для каждой транзакции из массива `transactions`:

View File

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

View File

@@ -3,6 +3,20 @@ server {
root /usr/share/nginx/html;
index index.html;
# Import endpoint — long timeout for LLM processing
location /api/import {
proxy_pass http://family-budget-backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cookie_path / /;
proxy_connect_timeout 5s;
proxy_read_timeout 600s;
client_max_body_size 15m;
}
# API — проксируем на backend (сервис из docker-compose)
location /api {
proxy_pass http://family-budget-backend:3000;

View File

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

View File

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

After

Width:  |  Height:  |  Size: 271 B

View File

@@ -10,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) {

View File

@@ -1,4 +1,7 @@
import type { ImportStatementResponse } from '@family-budget/shared';
import type {
ImportStatementResponse,
Import,
} from '@family-budget/shared';
import { api } from './client';
export async function importStatement(
@@ -11,3 +14,13 @@ export async function importStatement(
formData,
);
}
export async function getImports(): Promise<Import[]> {
return api.get<Import[]>('/api/imports');
}
export async function deleteImport(
id: number,
): Promise<{ deleted: number }> {
return api.delete<{ deleted: number }>(`/api/imports/${id}`);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
import { useState } from 'react';
import { clearAllTransactions } from '../api/transactions';
const CONFIRM_WORD = 'УДАЛИТЬ';
interface Props {
onClose: () => void;
onDone: () => void;
@@ -10,15 +8,11 @@ interface Props {
export function ClearHistoryModal({ onClose, onDone }: Props) {
const [check1, setCheck1] = useState(false);
const [confirmInput, setConfirmInput] = useState('');
const [check2, setCheck2] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const canConfirm =
check1 &&
confirmInput.trim().toUpperCase() === CONFIRM_WORD &&
check2;
const canConfirm = check1 && check2;
const handleConfirm = async () => {
if (!canConfirm || loading) return;
@@ -37,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="Закрыть">
&times;
</button>
</div>
<div className="modal-body">
<p className="clear-history-warn">
<div className="modal__body">
<p className="danger-note">
Все транзакции будут безвозвратно удалены. Счета и категории
сохранятся.
</p>
{error && <div className="alert alert-error">{error}</div>}
{error && <div className="alert alert--error">{error}</div>}
<div className="form-group form-group-checkbox clear-history-check">
<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>

View File

@@ -1,22 +1,96 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ClearHistoryModal } from './ClearHistoryModal';
import { DeleteImportModal } from './DeleteImportModal';
import { getImports } from '../api/import';
import type { Import } from '@family-budget/shared';
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function DataSection() {
const [showClearModal, setShowClearModal] = useState(false);
const [imports, setImports] = useState<Import[]>([]);
const [impToDelete, setImpToDelete] = useState<Import | null>(null);
const navigate = useNavigate();
useEffect(() => {
getImports().then(setImports).catch(() => {});
}, []);
const handleImportDeleted = () => {
setImpToDelete(null);
getImports().then(setImports).catch(() => {});
navigate('/history');
};
return (
<div className="data-section">
<div className="section-block">
<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>
);
}

View File

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

View File

@@ -24,6 +24,14 @@ function extractPattern(description: string): string {
.slice(0, 50);
}
function getCommissionAmountSigned(transaction: Transaction): number {
if (transaction.commission === 0) return 0;
const isCashbackIncome =
transaction.amountSigned === 0 &&
transaction.description.toLowerCase().includes('зачисление');
return isCashbackIncome ? transaction.commission : -transaction.commission;
}
export function EditTransactionModal({
transaction,
categories,
@@ -74,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="Закрыть">
&times;
</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 ? 'Сохранение...' : 'Сохранить'}

View File

@@ -59,67 +59,74 @@ export function ImportModal({ 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="Закрыть">
&times;
</button>
</div>
<div className="modal-body">
{error && <div className="alert alert-error">{error}</div>}
<div className="modal__body">
{error && <div className="alert alert--error">{error}</div>}
{!result && (
<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"
className="import-upload__input"
/>
{loading && (
<div className="import-loading">Импорт...</div>
<div className="import-upload__loading">Импорт...</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="Напр.: Текущий, Накопительный"
@@ -128,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()}
>
@@ -139,7 +146,7 @@ export function ImportModal({ onClose, onDone }: Props) {
)}
{aliasSaved && (
<div className="import-alias-saved">
<div className="import-result__alias-saved">
Алиас сохранён
</div>
)}
@@ -147,14 +154,14 @@ export function ImportModal({ onClose, onDone }: Props) {
)}
</div>
<div className="modal-footer">
<div className="modal__footer">
{result ? (
<button className="btn btn-primary" onClick={onDone}>
<button className="button button--primary modal__action" onClick={onDone}>
Готово
</button>
) : (
<button
className="btn btn-secondary"
className="button button--secondary modal__action"
onClick={onClose}
disabled={loading}
>

View File

@@ -1,18 +1,26 @@
import { useState, type ReactNode } from 'react';
import { useState, useEffect, type ReactNode } from 'react';
import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
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);
useEffect(() => {
getBackendVersion()
.then((r) => setBeVersion(r.version))
.catch(() => setBeVersion(null));
}, []);
return (
<div className="layout">
<div className="app-shell">
<button
type="button"
className="burger-btn"
className="menu-button"
aria-label="Открыть меню"
onClick={() => setDrawerOpen(true)}
>
@@ -25,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" />
@@ -58,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" />
@@ -73,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>
@@ -85,15 +93,23 @@ export function Layout({ children }: { children: ReactNode }) {
</NavLink>
</nav>
<div className="sidebar-footer">
<span className="sidebar-user">{user?.login}</span>
<button className="btn-logout" onClick={() => logout()}>
Выход
</button>
<div className="sidebar__footer">
<div className="sidebar__user-row">
<span className="sidebar__user">{user?.login}</span>
<button className="sidebar__logout" onClick={() => logout()}>
Выход
</button>
</div>
<div className="sidebar__meta">
<span className="sidebar__version">
FE {__FE_VERSION__} · BE {beVersion ?? '…'}
</span>
<span className="sidebar__copyright">© 2025 Семейный бюджет</span>
</div>
</div>
</aside>
<main className="main-content">{children}</main>
<main className="app-shell__main">{children}</main>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,19 +26,20 @@ export function TimeseriesChart({ data }: Props) {
const chartHeight = isMobile ? 250 : 300;
if (data.length === 0) {
return <div className="chart-empty">Нет данных за период</div>;
return <div className="state state--empty">Нет данных за период</div>;
}
const chartData = data.map((item) => ({
period: item.periodStart,
Расходы: Math.abs(item.expenseAmount) / 100,
Доходы: item.incomeAmount / 100,
Переводы: 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>

View File

@@ -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="Предыдущий период"
>
&larr;
</button>
)}
<div className="filter-dates">
<div className="filters__dates">
<input
type="date"
value={filters.from}
onChange={(e) => handleDateChange('from', e.target.value)}
/>
<span className="filter-separator">&mdash;</span>
<span className="filters__separator">&mdash;</span>
<input
type="date"
value={filters.to}
@@ -146,29 +147,30 @@ export function TransactionFilters({
{filters.periodMode !== 'custom' && (
<button
type="button"
className="btn-page"
className="icon-button"
onClick={() => navigate(1)}
aria-label="Следующий период"
title="Следующий период"
>
&rarr;
</button>
)}
</div>
<div className="filter-presets">
<div className="segmented-control segmented-control--compact">
<button
className={`btn-preset ${filters.periodMode === 'week' ? 'active' : ''}`}
className={`segmented-control__button ${filters.periodMode === 'week' ? 'segmented-control__button--active' : ''}`}
onClick={() => applyPreset('week')}
>
Неделя
</button>
<button
className={`btn-preset ${filters.periodMode === 'month' ? 'active' : ''}`}
className={`segmented-control__button ${filters.periodMode === 'month' ? 'segmented-control__button--active' : ''}`}
onClick={() => applyPreset('month')}
>
Месяц
</button>
<button
className={`btn-preset ${filters.periodMode === 'year' ? 'active' : ''}`}
className={`segmented-control__button ${filters.periodMode === 'year' ? 'segmented-control__button--active' : ''}`}
onClick={() => applyPreset('year')}
>
Год
@@ -176,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>

View File

@@ -8,11 +8,18 @@ interface Props {
}
const DIRECTION_CLASSES: Record<string, string> = {
income: 'amount-income',
expense: 'amount-expense',
transfer: 'amount-transfer',
income: 'money-amount--income',
expense: 'money-amount--expense',
transfer: 'money-amount--transfer',
};
function getCommissionAmountSigned(tx: Transaction): number {
if (tx.commission === 0) return 0;
const isCashbackIncome =
tx.amountSigned === 0 && tx.description.toLowerCase().includes('зачисление');
return isCashbackIncome ? tx.commission : -tx.commission;
}
function TransactionCard({
tx,
onEdit,
@@ -26,34 +33,39 @@ function TransactionCard({
return (
<div
className={`transaction-card ${isUnconfirmed ? 'row-unconfirmed' : ''}`}
className={`transaction-card ${isUnconfirmed ? 'transaction-card--unconfirmed' : ''}`}
>
<div className="transaction-card-header">
<span className="transaction-card-date">
<div className="transaction-card__header">
<span className="transaction-card__date">
{formatDateTime(tx.operationAt)}
</span>
<span className={`transaction-card-amount ${directionClass}`}>
<span className={`money-amount transaction-card__amount ${directionClass}`}>
{formatAmount(tx.amountSigned)}
</span>
</div>
<div className="transaction-card-body">
<span className="description-text">{tx.description}</span>
{tx.commission !== 0 && (
<div className="transaction-card__commission">
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
</div>
)}
<div className="transaction-card__body">
<span className="transaction-card__description">{tx.description}</span>
{tx.comment && (
<span className="comment-badge" title={tx.comment}>
<span className="comment-indicator" title={tx.comment}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</span>
)}
</div>
<div className="transaction-card-footer">
<span className="transaction-card-meta">
<div className="transaction-card__footer">
<span className="transaction-card__meta">
{tx.accountAlias || '—'} · {tx.categoryName || '—'}
</span>
<div className="transaction-card-actions">
<div className="transaction-card__actions">
{tx.categoryId != null && !tx.isCategoryConfirmed && (
<span
className="badge badge-warning"
className="badge badge--warning"
title="Категория не подтверждена"
>
?
@@ -61,8 +73,9 @@ function TransactionCard({
)}
<button
type="button"
className="btn-icon btn-icon-touch"
className="icon-button icon-button--touch"
onClick={() => onEdit(tx)}
aria-label="Редактировать операцию"
title="Редактировать"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -78,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} />
))}

View File

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

View File

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

View File

@@ -204,12 +204,20 @@ export function HistoryPage() {
return (
<div className="page">
<div className="page-header">
<h1>История операций</h1>
<div className="page__header">
<div>
<p className="page__eyebrow">Операции</p>
<h1 className="page__title">История операций</h1>
</div>
<button
className="btn btn-primary"
className="button button--primary 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>

View File

@@ -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 ? 'Вход...' : 'Войти'}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

257
package-lock.json generated
View File

@@ -24,7 +24,7 @@
"express": "^5.2.1",
"multer": "^2.1.1",
"openai": "^6.27.0",
"pdf-parse": "^2.4.5",
"pdf-parse": "1.1.1",
"pg": "^8.19.0",
"uuid": "^13.0.0"
},
@@ -40,9 +40,31 @@
"typescript": "^5.9.3"
}
},
"backend/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
},
"backend/node_modules/pdf-parse": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.1.tgz",
"integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==",
"license": "MIT",
"dependencies": {
"debug": "^3.1.0",
"node-ensure": "^0.0.0"
},
"engines": {
"node": ">=6.8.1"
}
},
"frontend": {
"name": "@family-budget/frontend",
"version": "0.1.0",
"version": "0.9.0",
"dependencies": {
"@family-budget/shared": "*",
"react": "^19.0.0",
@@ -89,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",
@@ -853,190 +876,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
"license": "MIT",
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.80",
"@napi-rs/canvas-darwin-arm64": "0.1.80",
"@napi-rs/canvas-darwin-x64": "0.1.80",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1556,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",
@@ -1634,6 +1474,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1773,6 +1614,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2796,6 +2638,12 @@
"node": ">= 0.6"
}
},
"node_modules/node-ensure": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -2885,43 +2733,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/pdf-parse": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
"license": "Apache-2.0",
"dependencies": {
"@napi-rs/canvas": "0.1.80",
"pdfjs-dist": "5.4.296"
},
"bin": {
"pdf-parse": "bin/cli.mjs"
},
"engines": {
"node": ">=20.16.0 <21 || >=22.3.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/pg": {
"version": "8.19.0",
"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",
@@ -3019,6 +2836,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3168,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"
}
@@ -3177,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"
},
@@ -4288,6 +4108,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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