7 Commits

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

Made-with: Cursor
2026-04-14 16:15:05 +03:00
e97203c4ab Merge pull request 'chore: adds versions and copyright' (#16) from feat/sidebar-versions-copyright into main
Reviewed-on: #16
2026-03-17 03:44:11 +00:00
35 changed files with 1897 additions and 1522 deletions

View File

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

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

View File

@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid';
import { pool } from '../db/pool'; import { pool } from '../db/pool';
import { config } from '../config'; import { config } from '../config';
import type { LoginRequest, MeResponse } from '@family-budget/shared'; import type { LoginRequest, MeResponse } from '@family-budget/shared';
import backendPackage from '../../package.json';
export async function login( export async function login(
body: LoginRequest, body: LoginRequest,
@@ -26,5 +27,8 @@ export async function logout(sessionId: string): Promise<void> {
} }
export async function me(sessionId: string): Promise<MeResponse> { 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( function computeFingerprint(
accountNumber: string, accountNumber: string,
@@ -84,15 +85,25 @@ function validateStructure(body: unknown): ValidationError | null {
if (typeof t.operationAt !== 'string' || !ISO_WITH_OFFSET.test(t.operationAt)) { 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` }; 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) { 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` }; return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].commission must be a non-negative integer` };
} }
if (typeof t.description !== 'string' || !t.description) { if (typeof t.description !== 'string' || !t.description) {
return { status: 400, error: 'BAD_REQUEST', message: `transactions[${i}].description must be a non-empty string` }; 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; return null;
@@ -164,21 +175,40 @@ export async function importStatement(
[accountId, data.bank, accountNumberMasked, data.transactions.length], [accountId, data.bank, accountNumberMasked, data.transactions.length],
); );
const importId = Number(importResult.rows[0].id); 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 // Insert transactions
const insertedIds: number[] = []; const insertedIds: number[] = [];
for (const tx of data.transactions) { for (const tx of data.transactions) {
const fp = computeFingerprint(data.statement.accountNumber, tx); 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( const result = await client.query(
`INSERT INTO transactions `INSERT INTO transactions
(account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id) (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, NULL, FALSE, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (account_id, fingerprint) DO NOTHING ON CONFLICT (account_id, fingerprint) DO NOTHING
RETURNING id`, RETURNING id`,
[accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, importId], [accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, categoryId, isCategoryConfirmed, importId],
); );
if (result.rows.length > 0) { if (result.rows.length > 0) {

View File

@@ -19,7 +19,7 @@ const PDF2JSON_PROMPT = `Ты — конвертер банковских вып
"transactions": [ "transactions": [
{ {
"operationAt": "<дата и время операции в формате ISO 8601 с offset>", "operationAt": "<дата и время операции в формате ISO 8601 с offset>",
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>, "amountSigned": <число: положительное для прихода, отрицательное для расхода; 0 допустим только если сумма фактически в commission и в description есть «Зачисление»>,
"commission": <число, целое, >= 0, в копейках>, "commission": <число, целое, >= 0, в копейках>,
"description": "<полное описание операции из выписки>" "description": "<полное описание операции из выписки>"
} }
@@ -30,6 +30,7 @@ const PDF2JSON_PROMPT = `Ты — конвертер банковских вып
1. Суммы — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456. 1. Суммы — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456.
2. amountSigned: приход — положительное, расход — отрицательное. 2. amountSigned: приход — положительное, расход — отрицательное.
amountSigned = 0 допускается только для кейса кэшбэка/зачисления, когда сумма указана в commission и в description есть «Зачисление».
3. operationAt — дата и время, если не указано — 00:00:00, offset +03:00 для МСК. 3. operationAt — дата и время, если не указано — 00:00:00, offset +03:00 для МСК.
4. commission — если не указана — 0. 4. commission — если не указана — 0.
5. description — полный текст операции как в выписке. 5. description — полный текст операции как в выписке.

View File

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

View File

@@ -10,7 +10,7 @@ export function App() {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
if (loading) { if (loading) {
return <div className="app-loading">Загрузка...</div>; return <div className="app-state app-state--loading">Загрузка...</div>;
} }
if (!user) { if (!user) {

View File

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

View File

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

View File

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

View File

@@ -31,24 +31,29 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
}; };
return ( return (
<div className="modal-overlay" onClick={onClose}> <div
className="modal-backdrop"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal" onClick={(e) => e.stopPropagation()}> <div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal__header">
<h2>Очистить историю операций</h2> <h2>Очистить историю операций</h2>
<button className="btn-close" onClick={onClose}> <button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
&times; &times;
</button> </button>
</div> </div>
<div className="modal-body"> <div className="modal__body">
<p className="clear-history-warn"> <p className="danger-note">
Все транзакции будут безвозвратно удалены. Счета и категории Все транзакции будут безвозвратно удалены. Счета и категории
сохранятся. сохранятся.
</p> </p>
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert--error">{error}</div>}
<div className="form-group form-group-checkbox clear-history-check"> <div className="field field--checkbox danger-note__check">
<label> <label>
<input <input
type="checkbox" type="checkbox"
@@ -59,7 +64,7 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
</label> </label>
</div> </div>
<div className="form-group form-group-checkbox clear-history-check"> <div className="field field--checkbox danger-note__check">
<label> <label>
<input <input
type="checkbox" type="checkbox"
@@ -72,15 +77,15 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
</div> </div>
</div> </div>
<div className="modal-footer"> <div className="modal__footer">
<button <button
className="btn btn-danger" className="button button--danger"
onClick={handleConfirm} onClick={handleConfirm}
disabled={!canConfirm || loading} disabled={!canConfirm || loading}
> >
{loading ? 'Удаление…' : 'Удалить всё'} {loading ? 'Удаление…' : 'Удалить всё'}
</button> </button>
<button className="btn btn-secondary" onClick={onClose}> <button className="button button--secondary" onClick={onClose}>
Отмена Отмена
</button> </button>
</div> </div>

View File

@@ -34,16 +34,16 @@ export function DataSection() {
return ( return (
<div className="data-section"> <div className="data-section">
<div className="section-block"> <div className="data-section__block">
<h3>История импортов</h3> <h3>История импортов</h3>
<p className="section-desc"> <p className="data-section__description">
Список импортов выписок. Можно удалить операции конкретного импорта. Список импортов выписок. Можно удалить операции конкретного импорта.
</p> </p>
{imports.length === 0 ? ( {imports.length === 0 ? (
<p className="muted">Импортов пока нет.</p> <p className="muted-text">Импортов пока нет.</p>
) : ( ) : (
<div className="table-responsive"> <div className="table-shell">
<table className="table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>Дата</th> <th>Дата</th>
@@ -67,7 +67,7 @@ export function DataSection() {
<td> <td>
<button <button
type="button" type="button"
className="btn btn-sm btn-danger" className="button button--danger button--small"
onClick={() => setImpToDelete(imp)} onClick={() => setImpToDelete(imp)}
disabled={imp.importedCount === 0} disabled={imp.importedCount === 0}
> >
@@ -82,15 +82,15 @@ export function DataSection() {
)} )}
</div> </div>
<div className="section-block"> <div className="data-section__block">
<h3>Очистка данных</h3> <h3>Очистка данных</h3>
<p className="section-desc"> <p className="data-section__description">
Очистить историю операций (все транзакции). Счета, категории и Очистить историю операций (все транзакции). Счета, категории и
правила сохранятся. правила сохранятся.
</p> </p>
<button <button
type="button" type="button"
className="btn btn-danger" className="button button--danger"
onClick={() => setShowClearModal(true)} onClick={() => setShowClearModal(true)}
> >
Очистить историю Очистить историю

View File

@@ -31,36 +31,41 @@ export function DeleteImportModal({ imp, onClose, onDone }: Props) {
}; };
return ( return (
<div className="modal-overlay" onClick={onClose}> <div
className="modal-backdrop"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal" onClick={(e) => e.stopPropagation()}> <div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal__header">
<h2>Удалить импорт</h2> <h2>Удалить импорт</h2>
<button className="btn-close" onClick={onClose}> <button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
&times; &times;
</button> </button>
</div> </div>
<div className="modal-body"> <div className="modal__body">
<p className="clear-history-warn"> <p className="danger-note">
Будут удалены все операции этого импорта ({imp.importedCount}{' '} Будут удалены все операции этого импорта ({imp.importedCount}{' '}
шт.): {imp.bank} / {accountLabel} шт.): {imp.bank} / {accountLabel}
</p> </p>
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert--error">{error}</div>}
<p>Действие необратимо.</p> <p>Действие необратимо.</p>
</div> </div>
<div className="modal-footer"> <div className="modal__footer">
<button <button
type="button" type="button"
className="btn btn-danger" className="button button--danger"
onClick={handleConfirm} onClick={handleConfirm}
disabled={loading} disabled={loading}
> >
{loading ? 'Удаление…' : 'Удалить'} {loading ? 'Удаление…' : 'Удалить'}
</button> </button>
<button type="button" className="btn btn-secondary" onClick={onClose}> <button type="button" className="button button--secondary" onClick={onClose}>
Отмена Отмена
</button> </button>
</div> </div>

View File

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

View File

@@ -59,17 +59,22 @@ export function ImportModal({ onClose, onDone }: Props) {
}; };
return ( return (
<div className="modal-overlay" onClick={onClose}> <div
className="modal-backdrop"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal" onClick={(e) => e.stopPropagation()}> <div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal__header">
<h2>Импорт выписки</h2> <h2>Импорт выписки</h2>
<button className="btn-close" onClick={onClose}> <button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
&times; &times;
</button> </button>
</div> </div>
<div className="modal-body"> <div className="modal__body">
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert--error">{error}</div>}
{!result && ( {!result && (
<div className="import-upload"> <div className="import-upload">
@@ -79,19 +84,19 @@ export function ImportModal({ onClose, onDone }: Props) {
type="file" type="file"
accept=".pdf,.json,application/pdf,application/json" accept=".pdf,.json,application/pdf,application/json"
onChange={handleFileChange} onChange={handleFileChange}
className="file-input" className="import-upload__input"
/> />
{loading && ( {loading && (
<div className="import-loading">Импорт...</div> <div className="import-upload__loading">Импорт...</div>
)} )}
</div> </div>
)} )}
{result && ( {result && (
<div className="import-result"> <div className="import-result">
<div className="import-result-icon" aria-hidden="true"></div> <div className="import-result__icon" aria-hidden="true"></div>
<h3>Импорт завершён</h3> <h3>Импорт завершён</h3>
<table className="import-stats"> <table className="import-result__stats">
<tbody> <tbody>
<tr> <tr>
<td>Счёт</td> <td>Счёт</td>
@@ -117,9 +122,9 @@ export function ImportModal({ onClose, onDone }: Props) {
</table> </table>
{result.isNewAccount && !aliasSaved && ( {result.isNewAccount && !aliasSaved && (
<div className="import-alias"> <div className="import-result__alias">
<label>Алиас для нового счёта</label> <label>Алиас для нового счёта</label>
<div className="import-alias-row"> <div className="import-result__alias-row">
<input <input
type="text" type="text"
placeholder="Напр.: Текущий, Накопительный" placeholder="Напр.: Текущий, Накопительный"
@@ -128,7 +133,7 @@ export function ImportModal({ onClose, onDone }: Props) {
maxLength={50} maxLength={50}
/> />
<button <button
className="btn btn-sm btn-primary" className="button button--primary button--small"
onClick={handleSaveAlias} onClick={handleSaveAlias}
disabled={!alias.trim()} disabled={!alias.trim()}
> >
@@ -139,7 +144,7 @@ export function ImportModal({ onClose, onDone }: Props) {
)} )}
{aliasSaved && ( {aliasSaved && (
<div className="import-alias-saved"> <div className="import-result__alias-saved">
Алиас сохранён Алиас сохранён
</div> </div>
)} )}
@@ -147,14 +152,14 @@ export function ImportModal({ onClose, onDone }: Props) {
)} )}
</div> </div>
<div className="modal-footer"> <div className="modal__footer">
{result ? ( {result ? (
<button className="btn btn-primary" onClick={onDone}> <button className="button button--primary" onClick={onDone}>
Готово Готово
</button> </button>
) : ( ) : (
<button <button
className="btn btn-secondary" className="button button--secondary"
onClick={onClose} onClick={onClose}
disabled={loading} disabled={loading}
> >

View File

@@ -17,10 +17,10 @@ export function Layout({ children }: { children: ReactNode }) {
}, []); }, []);
return ( return (
<div className="layout"> <div className="app-shell">
<button <button
type="button" type="button"
className="burger-btn" className="menu-button"
aria-label="Открыть меню" aria-label="Открыть меню"
onClick={() => setDrawerOpen(true)} onClick={() => setDrawerOpen(true)}
> >
@@ -33,23 +33,23 @@ export function Layout({ children }: { children: ReactNode }) {
{drawerOpen && ( {drawerOpen && (
<div <div
className="sidebar-overlay" className="app-shell__overlay"
aria-hidden="true" aria-hidden="true"
onClick={closeDrawer} onClick={closeDrawer}
/> />
)} )}
<aside className={`sidebar ${drawerOpen ? 'sidebar-open' : ''}`}> <aside className={`sidebar ${drawerOpen ? 'sidebar--open' : ''}`}>
<div className="sidebar-brand"> <div className="sidebar__brand">
<span className="sidebar-brand-icon"></span> <span className="sidebar__brand-icon"></span>
<span className="sidebar-brand-text">Семейный бюджет</span> <span className="sidebar__brand-text">Семейный бюджет</span>
</div> </div>
<nav className="sidebar-nav"> <nav className="sidebar__nav" aria-label="Основная навигация">
<NavLink <NavLink
to="/history" to="/history"
className={({ isActive }) => className={({ isActive }) =>
`nav-link${isActive ? ' active' : ''}` `sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}`
} }
onClick={closeDrawer} onClick={closeDrawer}
> >
@@ -66,7 +66,7 @@ export function Layout({ children }: { children: ReactNode }) {
<NavLink <NavLink
to="/analytics" to="/analytics"
className={({ isActive }) => className={({ isActive }) =>
`nav-link${isActive ? ' active' : ''}` `sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}`
} }
onClick={closeDrawer} onClick={closeDrawer}
> >
@@ -81,7 +81,7 @@ export function Layout({ children }: { children: ReactNode }) {
<NavLink <NavLink
to="/settings" to="/settings"
className={({ isActive }) => className={({ isActive }) =>
`nav-link${isActive ? ' active' : ''}` `sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}`
} }
onClick={closeDrawer} onClick={closeDrawer}
> >
@@ -93,23 +93,23 @@ export function Layout({ children }: { children: ReactNode }) {
</NavLink> </NavLink>
</nav> </nav>
<div className="sidebar-footer"> <div className="sidebar__footer">
<div className="sidebar-footer-top"> <div className="sidebar__user-row">
<span className="sidebar-user">{user?.login}</span> <span className="sidebar__user">{user?.login}</span>
<button className="btn-logout" onClick={() => logout()}> <button className="sidebar__logout" onClick={() => logout()}>
Выход Выход
</button> </button>
</div> </div>
<div className="sidebar-footer-bottom"> <div className="sidebar__meta">
<span className="sidebar-version"> <span className="sidebar__version">
FE {__FE_VERSION__} · BE {beVersion ?? '…'} FE {__FE_VERSION__} · BE {beVersion ?? '…'}
</span> </span>
<span className="sidebar-copyright">© 2025 Семейный бюджет</span> <span className="sidebar__copyright">© 2025 Семейный бюджет</span>
</div> </div>
</div> </div>
</aside> </aside>
<main className="main-content">{children}</main> <main className="app-shell__main">{children}</main>
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,11 +8,18 @@ interface Props {
} }
const DIRECTION_CLASSES: Record<string, string> = { const DIRECTION_CLASSES: Record<string, string> = {
income: 'amount-income', income: 'money-amount--income',
expense: 'amount-expense', expense: 'money-amount--expense',
transfer: 'amount-transfer', 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({ function TransactionCard({
tx, tx,
onEdit, onEdit,
@@ -26,34 +33,39 @@ function TransactionCard({
return ( return (
<div <div
className={`transaction-card ${isUnconfirmed ? 'row-unconfirmed' : ''}`} className={`transaction-card ${isUnconfirmed ? 'transaction-card--unconfirmed' : ''}`}
> >
<div className="transaction-card-header"> <div className="transaction-card__header">
<span className="transaction-card-date"> <span className="transaction-card__date">
{formatDateTime(tx.operationAt)} {formatDateTime(tx.operationAt)}
</span> </span>
<span className={`transaction-card-amount ${directionClass}`}> <span className={`money-amount transaction-card__amount ${directionClass}`}>
{formatAmount(tx.amountSigned)} {formatAmount(tx.amountSigned)}
</span> </span>
</div> </div>
<div className="transaction-card-body"> {tx.commission !== 0 && (
<span className="description-text">{tx.description}</span> <div className="transaction-card__commission">
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
</div>
)}
<div className="transaction-card__body">
<span className="transaction-card__description">{tx.description}</span>
{tx.comment && ( {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"> <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" /> <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg> </svg>
</span> </span>
)} )}
</div> </div>
<div className="transaction-card-footer"> <div className="transaction-card__footer">
<span className="transaction-card-meta"> <span className="transaction-card__meta">
{tx.accountAlias || '—'} · {tx.categoryName || '—'} {tx.accountAlias || '—'} · {tx.categoryName || '—'}
</span> </span>
<div className="transaction-card-actions"> <div className="transaction-card__actions">
{tx.categoryId != null && !tx.isCategoryConfirmed && ( {tx.categoryId != null && !tx.isCategoryConfirmed && (
<span <span
className="badge badge-warning" className="badge badge--warning"
title="Категория не подтверждена" title="Категория не подтверждена"
> >
? ?
@@ -61,8 +73,9 @@ function TransactionCard({
)} )}
<button <button
type="button" type="button"
className="btn-icon btn-icon-touch" className="icon-button icon-button--touch"
onClick={() => onEdit(tx)} onClick={() => onEdit(tx)}
aria-label="Редактировать операцию"
title="Редактировать" title="Редактировать"
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -78,16 +91,16 @@ function TransactionCard({
export function TransactionTable({ transactions, loading, onEdit }: Props) { export function TransactionTable({ transactions, loading, onEdit }: Props) {
if (loading) { if (loading) {
return <div className="table-loading">Загрузка операций...</div>; return <div className="state state--loading">Загрузка операций...</div>;
} }
if (transactions.length === 0) { if (transactions.length === 0) {
return <div className="table-empty">Операции не найдены</div>; return <div className="state state--empty">Операции не найдены</div>;
} }
return ( return (
<> <>
<div className="table-wrapper table-desktop"> <div className="table-shell table-shell--desktop">
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
@@ -96,7 +109,7 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
<th>Сумма</th> <th>Сумма</th>
<th>Описание</th> <th>Описание</th>
<th>Категория</th> <th>Категория</th>
<th className="th-center">Статус</th> <th className="data-table__head-cell data-table__head-cell--center">Статус</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -106,38 +119,43 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
key={tx.id} key={tx.id}
className={ className={
!tx.isCategoryConfirmed && tx.categoryId !tx.isCategoryConfirmed && tx.categoryId
? 'row-unconfirmed' ? 'data-table__row--unconfirmed'
: '' : ''
} }
> >
<td className="td-nowrap"> <td className="data-table__cell data-table__cell--nowrap">
{formatDateTime(tx.operationAt)} {formatDateTime(tx.operationAt)}
</td> </td>
<td className="td-nowrap">{tx.accountAlias || '—'}</td> <td className="data-table__cell data-table__cell--nowrap">{tx.accountAlias || '—'}</td>
<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>
<td className="td-description"> <td className="data-table__cell data-table__cell--description">
<span className="description-text">{tx.description}</span> <span className="data-table__description">{tx.description}</span>
{tx.comment && ( {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"> <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" /> <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg> </svg>
</span> </span>
)} )}
</td> </td>
<td className="td-nowrap"> <td className="data-table__cell data-table__cell--nowrap">
{tx.categoryName || ( {tx.categoryName || (
<span className="text-muted"></span> <span className="muted-text"></span>
)} )}
</td> </td>
<td className="td-center"> <td className="data-table__cell data-table__cell--center">
{tx.categoryId != null && !tx.isCategoryConfirmed && ( {tx.categoryId != null && !tx.isCategoryConfirmed && (
<span <span
className="badge badge-warning" className="badge badge--warning"
title="Категория не подтверждена" title="Категория не подтверждена"
> >
? ?
@@ -146,8 +164,9 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
</td> </td>
<td> <td>
<button <button
className="btn-icon" className="icon-button"
onClick={() => onEdit(tx)} onClick={() => onEdit(tx)}
aria-label="Редактировать операцию"
title="Редактировать" title="Редактировать"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <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> </table>
</div> </div>
<div className="transaction-cards transaction-mobile"> <div className="transaction-list transaction-list--mobile">
{transactions.map((tx) => ( {transactions.map((tx) => (
<TransactionCard key={tx.id} tx={tx} onEdit={onEdit} /> <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'; import { setOnUnauthorized } from '../api/client';
interface AuthState { interface AuthState {
user: { login: string } | null; user: { login: string; backendVersion: string } | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
@@ -20,7 +20,7 @@ interface AuthState {
const AuthContext = createContext<AuthState | null>(null); const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) { 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 [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -31,7 +31,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
setOnUnauthorized(clearUser); setOnUnauthorized(clearUser);
getMe() getMe()
.then((me) => setUser({ login: me.login })) .then((me) => setUser({ login: me.login, backendVersion: me.backendVersion }))
.catch(() => setUser(null)) .catch(() => setUser(null))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [clearUser]); }, [clearUser]);
@@ -41,7 +41,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try { try {
await apiLogin({ login: username, password }); await apiLogin({ login: username, password });
const me = await getMe(); const me = await getMe();
setUser({ login: me.login }); setUser({ login: me.login, backendVersion: me.backendVersion });
} catch (e: unknown) { } catch (e: unknown) {
const msg = e instanceof Error && e.message === 'Failed to fetch' const msg = e instanceof Error && e.message === 'Failed to fetch'
? 'Сервер недоступен. Проверьте, что backend запущен и NPM проксирует /api на порт 3000.' ? 'Сервер недоступен. Проверьте, что backend запущен и NPM проксирует /api на порт 3000.'

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

2
package-lock.json generated
View File

@@ -64,7 +64,7 @@
}, },
"frontend": { "frontend": {
"name": "@family-budget/frontend", "name": "@family-budget/frontend",
"version": "0.1.0", "version": "0.8.6",
"dependencies": { "dependencies": {
"@family-budget/shared": "*", "@family-budget/shared": "*",
"react": "^19.0.0", "react": "^19.0.0",

View File

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

View File

@@ -1,75 +1,231 @@
Ты — конвертер банковских выписок. Твоя задача: извлечь данные из прикреплённого PDF банковской выписки и вернуть строго один валидный JSON-объект в формате ниже. Никакого текста до или после JSON, только сам объект. # Инструкция агента: конвертация банковской выписки ВТБ (PDF → JSON)
## Структура выходного JSON ## Цель
Преобразовать PDF-выписку банка ВТБ в JSON-файл строго по схеме 1.0.
На выходе — валидный JSON-файл без потерь операций, с правильными суммами в копейках и корректной проверкой баланса.
---
## Схема JSON (schema 1.0)
```json ```json
{ {
"schemaVersion": "1.0", "schemaVersion": "1.0",
"bank": "<название банка из выписки>", "bank": "VTB",
"statement": { "statement": {
"accountNumber": "<номер счёта, только цифры, без пробелов>", "accountNumber": "string",
"currency": "RUB", "currency": "RUB",
"openingBalance": <число в копейках, целое>, "openingBalance": integer,
"closingBalance": <число в копейках, целое>, "closingBalance": integer,
"exportedAt": "<дата экспорта в формате ISO 8601 с offset, например 2026-02-27T13:23:00+03:00>" "exportedAt": "ISO 8601 string"
}, },
"transactions": [ "transactions": [
{ {
"operationAt": "<дата и время операции в формате ISO 8601 с offset>", "operationAt": "ISO 8601 string",
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>, "amountSigned": integer,
"commission": <число, целое, >= 0, в копейках>, "commission": integer,
"description": "<полное описание операции из выписки>" "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` не должен быть пустым. ### 2.2 Правила маппинга
- Все числа — целые.
- Даты — строго в формате ISO 8601 с offset.
- currency всегда "RUB".
- schemaVersion всегда "1.0".
## Пример одной транзакции **`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 ```json
{ {
"operationAt": "2026-02-26T14:06:00+03:00", "operationAt": "2026-03-29T20:38:01+03:00",
"amountSigned": -50000, "amountSigned": -500000,
"commission": 0, "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", "name": "@family-budget/shared",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

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

View File

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