Compare commits
10 Commits
fix/modal-
...
feature/ne
| Author | SHA1 | Date | |
|---|---|---|---|
| 36562f8d78 | |||
| 1cbef453b0 | |||
| 019714a8cf | |||
| a79629a60d | |||
| ea70be8e5a | |||
| 2c98d9c534 | |||
| 2422ca44b0 | |||
| 6610e43200 | |||
|
|
ec62a0591e | ||
| 97b61de092 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,4 +23,5 @@ history.xlsx
|
|||||||
match_analysis.py
|
match_analysis.py
|
||||||
match_report.txt
|
match_report.txt
|
||||||
statements/
|
statements/
|
||||||
|
temp/
|
||||||
.cursor/
|
.cursor/
|
||||||
|
|||||||
15
CHANGELOG.md
Normal file
15
CHANGELOG.md
Normal 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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/backend",
|
"name": "@family-budget/backend",
|
||||||
"version": "0.5.12",
|
"version": "0.6.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/app.ts",
|
"dev": "tsx watch src/app.ts",
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
"start": "node dist/app.js",
|
"start": "node dist/app.js",
|
||||||
"migrate": "tsx src/db/migrate.ts",
|
"migrate": "tsx src/db/migrate.ts",
|
||||||
"migrate:prod": "node dist/db/migrate.js",
|
"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"
|
"test:llm": "tsx src/scripts/testLlm.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
42
backend/src/services/analytics.integration.test.ts
Normal file
42
backend/src/services/analytics.integration.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { pool } from '../db/pool';
|
||||||
|
import { getByCategory, getSummary, getTimeseries } from './analytics';
|
||||||
|
|
||||||
|
async function testQueries(): Promise<void> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const account = await client.query("INSERT INTO accounts (bank, account_number, currency) VALUES ('TEST', 'analytics-test-1', 'RUB') RETURNING id");
|
||||||
|
const otherAccount = await client.query("INSERT INTO accounts (bank, account_number, currency) VALUES ('TEST', 'analytics-test-2', 'RUB') RETURNING id");
|
||||||
|
const categories = await client.query("INSERT INTO categories (name, type) VALUES ('Тест расход', 'expense'), ('Тест доход', 'income'), ('Тест перевод', 'transfer') RETURNING id, type");
|
||||||
|
const accountId = Number(account.rows[0].id);
|
||||||
|
const otherAccountId = Number(otherAccount.rows[0].id);
|
||||||
|
const categoryId = Object.fromEntries(categories.rows.map((row) => [row.type, Number(row.id)]));
|
||||||
|
const insert = (account: number, at: string, amount: number, category: number, fingerprint: string) => client.query(
|
||||||
|
"INSERT INTO transactions (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) VALUES ($1, $2, $3, 0, 'test', CASE WHEN $3 < 0 THEN 'expense' ELSE 'income' END, $4, $5, TRUE)",
|
||||||
|
[account, at, amount, fingerprint, category],
|
||||||
|
);
|
||||||
|
|
||||||
|
await insert(accountId, '2026-07-01T12:00:00+03:00', -10_000, categoryId.expense, 'analytics-test-1');
|
||||||
|
await insert(accountId, '2026-07-02T12:00:00+03:00', 4_000, categoryId.expense, 'analytics-test-2');
|
||||||
|
await insert(accountId, '2026-07-03T12:00:00+03:00', 20_000, categoryId.income, 'analytics-test-3');
|
||||||
|
await insert(accountId, '2026-07-04T12:00:00+03:00', -5_000, categoryId.income, 'analytics-test-4');
|
||||||
|
await insert(accountId, '2026-07-05T12:00:00+03:00', -3_000, categoryId.transfer, 'analytics-test-5');
|
||||||
|
await insert(otherAccountId, '2026-07-01T12:00:00+03:00', -99_000, categoryId.expense, 'analytics-test-6');
|
||||||
|
await insert(accountId, '2026-08-01T12:00:00+03:00', -7_000, categoryId.expense, 'analytics-test-7');
|
||||||
|
|
||||||
|
const params = { from: '2026-07-01', to: '2026-07-31', accountId, onlyConfirmed: true };
|
||||||
|
const summary = await getSummary(params, client);
|
||||||
|
assert.deepEqual({ expense: summary.totalExpense, income: summary.totalIncome, net: summary.net, transferOut: summary.transferOutflow }, { expense: 6_000, income: 15_000, net: 9_000, transferOut: 3_000 });
|
||||||
|
assert.equal((await getSummary({ ...params, from: '2026-08-01', to: '2026-08-31' }, client)).totalExpense, 7_000);
|
||||||
|
assert.deepEqual((await getByCategory(params, client)).map((item) => item.amount), [6_000]);
|
||||||
|
const timeseries = await getTimeseries({ ...params, granularity: 'month' }, client);
|
||||||
|
assert.equal(timeseries[0].expenseAmount, 6_000);
|
||||||
|
assert.equal(timeseries[0].incomeAmount, 15_000);
|
||||||
|
} finally {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testQueries().then(() => console.log('analytics SQL: OK')).finally(() => pool.end());
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { pool } from '../db/pool';
|
import { pool } from '../db/pool';
|
||||||
|
import { effectiveAmountCase } from './analyticsSemantics';
|
||||||
import type {
|
import type {
|
||||||
AnalyticsSummaryResponse,
|
AnalyticsSummaryResponse,
|
||||||
TopCategory,
|
TopCategory,
|
||||||
@@ -7,6 +8,8 @@ import type {
|
|||||||
Granularity,
|
Granularity,
|
||||||
} from '@family-budget/shared';
|
} from '@family-budget/shared';
|
||||||
|
|
||||||
|
type Queryable = Pick<typeof pool, 'query'>;
|
||||||
|
|
||||||
interface BaseParams {
|
interface BaseParams {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
@@ -14,6 +17,24 @@ interface BaseParams {
|
|||||||
onlyConfirmed?: boolean;
|
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(
|
function buildBaseConditions(
|
||||||
params: BaseParams,
|
params: BaseParams,
|
||||||
startIdx: number,
|
startIdx: number,
|
||||||
@@ -42,96 +63,45 @@ function buildBaseConditions(
|
|||||||
return { conditions, values, nextIdx: idx };
|
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 { conditions, values } = buildBaseConditions(params, 1);
|
||||||
const where = 'WHERE ' + conditions.join(' AND ');
|
const where = 'WHERE ' + conditions.join(' AND ');
|
||||||
|
|
||||||
const totalsResult = await pool.query(
|
const totalsResult = await db.query(
|
||||||
`WITH investment_ref AS (
|
`${analyticsTransactions(where)},
|
||||||
SELECT (
|
category_net AS (
|
||||||
SELECT id
|
SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount
|
||||||
FROM categories
|
FROM analytics_transactions
|
||||||
WHERE name = 'Инвестиции' AND type = 'transfer'
|
GROUP BY category_id, category_name, analytic_type
|
||||||
ORDER BY id ASC
|
|
||||||
LIMIT 1
|
|
||||||
) AS investment_category_id
|
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(
|
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'expense'), 0)::bigint AS total_expense,
|
||||||
CASE
|
COALESCE(SUM(GREATEST(amount, 0)) FILTER (WHERE analytic_type = 'income'), 0)::bigint AS total_income,
|
||||||
WHEN (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
|
COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_inflow,
|
||||||
AND (
|
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_outflow,
|
||||||
ir.investment_category_id IS NULL
|
COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_inflow,
|
||||||
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow
|
||||||
)
|
FROM category_net`,
|
||||||
THEN ABS(t.amount_signed) + t.commission
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
), 0)::bigint AS total_expense,
|
|
||||||
COALESCE(SUM(
|
|
||||||
CASE
|
|
||||||
WHEN t.direction = 'income'
|
|
||||||
AND (
|
|
||||||
ir.investment_category_id IS NULL
|
|
||||||
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
|
||||||
)
|
|
||||||
THEN t.amount_signed + t.commission
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
), 0)::bigint AS total_income,
|
|
||||||
COALESCE(SUM(
|
|
||||||
CASE
|
|
||||||
WHEN t.amount_signed < 0
|
|
||||||
AND ir.investment_category_id IS NOT NULL
|
|
||||||
AND t.category_id = ir.investment_category_id
|
|
||||||
THEN ABS(t.amount_signed) + t.commission
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
), 0)::bigint AS investment_outflow,
|
|
||||||
COALESCE(SUM(
|
|
||||||
CASE
|
|
||||||
WHEN t.amount_signed > 0
|
|
||||||
AND ir.investment_category_id IS NOT NULL
|
|
||||||
AND t.category_id = ir.investment_category_id
|
|
||||||
THEN t.amount_signed + t.commission
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
), 0)::bigint AS investment_income_excluded
|
|
||||||
FROM transactions t
|
|
||||||
CROSS JOIN investment_ref ir
|
|
||||||
${where}`,
|
|
||||||
values,
|
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 cashInflow = Number(totalsResult.rows[0].cash_inflow);
|
||||||
const investmentIncomeExcluded = Number(totalsResult.rows[0].investment_income_excluded);
|
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(
|
const topResult = await db.query(
|
||||||
`WITH investment_ref AS (
|
`${analyticsTransactions(where)}
|
||||||
SELECT (
|
SELECT category_id::bigint, category_name, GREATEST(-SUM(effective_amount), 0)::bigint AS amount
|
||||||
SELECT id
|
FROM analytics_transactions
|
||||||
FROM categories
|
WHERE analytic_type = 'expense'
|
||||||
WHERE name = 'Инвестиции' AND type = 'transfer'
|
GROUP BY category_id, category_name
|
||||||
ORDER BY id ASC
|
HAVING SUM(effective_amount) < 0
|
||||||
LIMIT 1
|
|
||||||
) AS investment_category_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
COALESCE(t.category_id, 0)::bigint AS category_id,
|
|
||||||
COALESCE(c.name, 'Без категории') AS category_name,
|
|
||||||
SUM(ABS(t.amount_signed) + t.commission)::bigint AS amount
|
|
||||||
FROM transactions t
|
|
||||||
CROSS JOIN investment_ref ir
|
|
||||||
LEFT JOIN categories c ON c.id = t.category_id
|
|
||||||
${where}
|
|
||||||
AND (t.direction = 'expense' 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,
|
||||||
@@ -148,68 +118,39 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
|
|||||||
totalExpense,
|
totalExpense,
|
||||||
totalIncome,
|
totalIncome,
|
||||||
net: totalIncome - totalExpense,
|
net: totalIncome - totalExpense,
|
||||||
investmentOutflow,
|
cashInflow,
|
||||||
investmentIncomeExcluded,
|
cashOutflow,
|
||||||
|
transferInflow,
|
||||||
|
transferOutflow,
|
||||||
|
cashNet: cashInflow - cashOutflow,
|
||||||
topCategories,
|
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 { conditions, values } = buildBaseConditions(params, 1);
|
||||||
const where = 'WHERE ' + conditions.join(' AND ');
|
const where = 'WHERE ' + conditions.join(' AND ');
|
||||||
|
|
||||||
const totalResult = await pool.query(
|
const { rows } = await db.query(
|
||||||
`WITH investment_ref AS (
|
`${analyticsTransactions(where)}
|
||||||
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
|
|
||||||
CROSS JOIN investment_ref ir
|
|
||||||
${where}
|
|
||||||
AND (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
|
|
||||||
AND (
|
|
||||||
ir.investment_category_id IS NULL
|
|
||||||
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
|
||||||
)`,
|
|
||||||
values,
|
|
||||||
);
|
|
||||||
const total = Number(totalResult.rows[0].total);
|
|
||||||
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`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
|
SELECT
|
||||||
COALESCE(t.category_id, 0)::bigint AS category_id,
|
category_id::bigint,
|
||||||
COALESCE(c.name, 'Без категории') AS category_name,
|
category_name,
|
||||||
SUM(ABS(t.amount_signed) + t.commission)::bigint AS amount,
|
GREATEST(-SUM(effective_amount), 0)::bigint AS amount,
|
||||||
COUNT(*)::int AS tx_count
|
COUNT(*)::int AS tx_count
|
||||||
FROM transactions t
|
FROM analytics_transactions
|
||||||
CROSS JOIN investment_ref ir
|
WHERE analytic_type = 'expense'
|
||||||
LEFT JOIN categories c ON c.id = t.category_id
|
GROUP BY category_id, category_name
|
||||||
${where}
|
HAVING SUM(effective_amount) < 0
|
||||||
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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const total = rows.reduce((sum, row) => sum + Number(row.amount), 0);
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
categoryId: r.category_id != null ? Number(r.category_id) : 0,
|
categoryId: r.category_id != null ? Number(r.category_id) : 0,
|
||||||
categoryName: r.category_name ?? 'Без категории',
|
categoryName: r.category_name ?? 'Без категории',
|
||||||
@@ -221,6 +162,7 @@ export async function getByCategory(params: BaseParams): Promise<ByCategoryItem[
|
|||||||
|
|
||||||
export async function getTimeseries(
|
export async function getTimeseries(
|
||||||
params: BaseParams & { categoryId?: number; granularity: Granularity },
|
params: BaseParams & { categoryId?: number; granularity: Granularity },
|
||||||
|
db: Queryable = pool,
|
||||||
): Promise<TimeseriesItem[]> {
|
): Promise<TimeseriesItem[]> {
|
||||||
let truncExpr: string;
|
let truncExpr: string;
|
||||||
let intervalStr: string;
|
let intervalStr: string;
|
||||||
@@ -265,61 +207,38 @@ export async function getTimeseries(
|
|||||||
|
|
||||||
const txWhere = txConditions.join(' AND ');
|
const txWhere = txConditions.join(' AND ');
|
||||||
|
|
||||||
const { rows } = await pool.query(
|
const { rows } = await db.query(
|
||||||
`WITH periods AS (
|
`WITH periods AS (
|
||||||
SELECT
|
SELECT
|
||||||
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 (
|
period_transactions AS (
|
||||||
SELECT (
|
SELECT
|
||||||
SELECT id
|
p.period_start,
|
||||||
FROM categories
|
p.period_end,
|
||||||
WHERE name = 'Инвестиции' AND type = 'transfer'
|
COALESCE(c.type, t.direction) AS analytic_type,
|
||||||
ORDER BY id ASC
|
COALESCE(t.category_id, 0) AS category_id,
|
||||||
LIMIT 1
|
${effectiveAmount} AS effective_amount
|
||||||
) AS investment_category_id
|
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
|
SELECT
|
||||||
p.period_start,
|
period_start,
|
||||||
p.period_end,
|
period_end,
|
||||||
COALESCE(SUM(
|
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'expense'), 0)::bigint AS expense_amount,
|
||||||
CASE
|
COALESCE(SUM(GREATEST(amount, 0)) FILTER (WHERE analytic_type = 'income'), 0)::bigint AS income_amount,
|
||||||
WHEN (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
|
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow
|
||||||
AND (
|
FROM period_category_net
|
||||||
ir.investment_category_id IS NULL
|
GROUP BY period_start, period_end
|
||||||
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
ORDER BY period_start`,
|
||||||
)
|
|
||||||
THEN ABS(t.amount_signed) + t.commission
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
), 0)::bigint AS expense_amount,
|
|
||||||
COALESCE(SUM(
|
|
||||||
CASE
|
|
||||||
WHEN t.direction = 'income'
|
|
||||||
AND (
|
|
||||||
ir.investment_category_id IS NULL
|
|
||||||
OR t.category_id IS DISTINCT FROM ir.investment_category_id
|
|
||||||
)
|
|
||||||
THEN t.amount_signed + t.commission
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
), 0)::bigint AS income_amount,
|
|
||||||
COALESCE(SUM(
|
|
||||||
CASE
|
|
||||||
WHEN t.amount_signed < 0
|
|
||||||
AND ir.investment_category_id IS NOT NULL
|
|
||||||
AND t.category_id = ir.investment_category_id
|
|
||||||
THEN ABS(t.amount_signed) + t.commission
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
), 0)::bigint AS investment_outflow
|
|
||||||
FROM periods p
|
|
||||||
CROSS JOIN investment_ref ir
|
|
||||||
LEFT JOIN transactions t ON ${txWhere}
|
|
||||||
GROUP BY p.period_start, p.period_end
|
|
||||||
ORDER BY p.period_start`,
|
|
||||||
values,
|
values,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -328,6 +247,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),
|
transferOutflow: Number(r.transfer_outflow),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
25
backend/src/services/analyticsSemantics.test.ts
Normal file
25
backend/src/services/analyticsSemantics.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { getEffectiveAmount, getNetAmount } from './analyticsSemantics';
|
||||||
|
|
||||||
|
assert.equal(getNetAmount('expense', [
|
||||||
|
{ amountSigned: -10_000, commission: 0 },
|
||||||
|
{ amountSigned: 4_000, commission: 0 },
|
||||||
|
]), 6_000, 'возврат уменьшает расход своей категории');
|
||||||
|
|
||||||
|
assert.equal(getNetAmount('income', [
|
||||||
|
{ amountSigned: 20_000, commission: 0 },
|
||||||
|
{ amountSigned: -5_000, commission: 0 },
|
||||||
|
]), 15_000, 'корректировка уменьшает доход');
|
||||||
|
|
||||||
|
assert.equal(getNetAmount('transfer', [
|
||||||
|
{ amountSigned: -10_000, commission: 0 },
|
||||||
|
{ amountSigned: 10_000, commission: 0 },
|
||||||
|
]), 0, 'перевод не является доходом или расходом');
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
getEffectiveAmount({ amountSigned: 0, commission: 500, description: 'Зачисление кэшбэка' }),
|
||||||
|
500,
|
||||||
|
'кэшбэк учитывается как поступление',
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('analytics semantics: OK');
|
||||||
32
backend/src/services/analyticsSemantics.ts
Normal file
32
backend/src/services/analyticsSemantics.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export type AnalyticType = 'expense' | 'income' | 'transfer';
|
||||||
|
|
||||||
|
interface AmountInput {
|
||||||
|
amountSigned: number;
|
||||||
|
commission: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectiveAmount({ amountSigned, commission, description = '' }: AmountInput): number {
|
||||||
|
if (amountSigned === 0 && commission > 0 && description.toLowerCase().includes('зачисление')) {
|
||||||
|
return commission;
|
||||||
|
}
|
||||||
|
return amountSigned < 0 ? amountSigned - commission : amountSigned + commission;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNetAmount(type: AnalyticType, amounts: AmountInput[]): number {
|
||||||
|
const amount = amounts.reduce((sum, item) => sum + getEffectiveAmount(item), 0);
|
||||||
|
if (type === 'expense') return Math.max(-amount, 0);
|
||||||
|
if (type === 'income') return Math.max(amount, 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveAmountCase(alias: string): string {
|
||||||
|
return `
|
||||||
|
CASE
|
||||||
|
WHEN ${alias}.amount_signed = 0 AND ${alias}.commission > 0
|
||||||
|
AND ${alias}.description ILIKE '%зачисление%'
|
||||||
|
THEN ${alias}.commission
|
||||||
|
WHEN ${alias}.amount_signed < 0 THEN ${alias}.amount_signed - ${alias}.commission
|
||||||
|
ELSE ${alias}.amount_signed + ${alias}.commission
|
||||||
|
END`;
|
||||||
|
}
|
||||||
@@ -37,6 +37,26 @@
|
|||||||
- Чистый результат (`net = totalIncome - totalExpense`).
|
- Чистый результат (`net = totalIncome - totalExpense`).
|
||||||
- Топ-3–5 категорий по расходам (сумма, доля).
|
- Топ-3–5 категорий по расходам (сумма, доля).
|
||||||
|
|
||||||
|
#### Нетто-правила
|
||||||
|
|
||||||
|
Тип назначенной категории определяет экономический смысл операции:
|
||||||
|
|
||||||
|
- `expense`: отрицательная сумма увеличивает расход, положительная сумма
|
||||||
|
(возврат) уменьшает его; в отчёте категория не может стать отрицательным
|
||||||
|
расходом;
|
||||||
|
- `income`: положительная сумма увеличивает доход, отрицательная уменьшает
|
||||||
|
его;
|
||||||
|
- `transfer`: не учитывается в реальных доходах и расходах.
|
||||||
|
|
||||||
|
Если возврат не удалось определить правилом, пользователь вручную назначает
|
||||||
|
ему ту же расходную категорию, что у исходной покупки. Отдельная категория
|
||||||
|
«Возврат» и связывание двух операций не требуются.
|
||||||
|
|
||||||
|
Для контроля остатков сводка отдельно показывает движение денежных средств:
|
||||||
|
входящий и исходящий поток, внутренние переводы и чистое изменение денег.
|
||||||
|
Переводы между картой, накопительными и инвестиционными счетами остаются в
|
||||||
|
этом блоке, но не искажают доходы и расходы.
|
||||||
|
|
||||||
Реализуется через эндпоинт `GET /api/analytics/summary`.
|
Реализуется через эндпоинт `GET /api/analytics/summary`.
|
||||||
|
|
||||||
Параметры:
|
Параметры:
|
||||||
@@ -52,6 +72,11 @@
|
|||||||
"totalExpense": 12345600,
|
"totalExpense": 12345600,
|
||||||
"totalIncome": 20000000,
|
"totalIncome": 20000000,
|
||||||
"net": 7654400,
|
"net": 7654400,
|
||||||
|
"cashInflow": 26000000,
|
||||||
|
"cashOutflow": 18345600,
|
||||||
|
"transferInflow": 5000000,
|
||||||
|
"transferOutflow": 5000000,
|
||||||
|
"cashNet": 7654400,
|
||||||
"topCategories": [
|
"topCategories": [
|
||||||
{ "categoryId": 1, "categoryName": "Продукты", "amount": 4500000, "share": 0.36 },
|
{ "categoryId": 1, "categoryName": "Продукты", "amount": 4500000, "share": 0.36 },
|
||||||
{ "categoryId": 2, "categoryName": "ЖКХ", "amount": 2500000, "share": 0.20 }
|
{ "categoryId": 2, "categoryName": "ЖКХ", "amount": 2500000, "share": 0.20 }
|
||||||
|
|||||||
@@ -141,6 +141,12 @@ accountNumber|operationAt|amountSigned|commission|normalizedDescription
|
|||||||
|
|
||||||
Список ключевых фраз для `"transfer"` может расширяться; в MVP используется фиксированный набор.
|
Список ключевых фраз для `"transfer"` может расширяться; в MVP используется фиксированный набор.
|
||||||
|
|
||||||
|
`direction` — исходная банковская классификация. В аналитике окончательный
|
||||||
|
экономический тип определяется типом назначенной категории: операция,
|
||||||
|
отнесённая к расходной категории, является расходом или возвратом независимо
|
||||||
|
от исходного `direction`; категория типа `transfer` исключает операцию из
|
||||||
|
реальных доходов и расходов.
|
||||||
|
|
||||||
### Импорт транзакций
|
### Импорт транзакций
|
||||||
|
|
||||||
Для каждой транзакции из массива `transactions`:
|
Для каждой транзакции из массива `transactions`:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/frontend",
|
"name": "@family-budget/frontend",
|
||||||
"version": "0.8.5",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -36,28 +36,28 @@ export function AccountsList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="section-loading">Загрузка...</div>;
|
return <div className="state state--loading">Загрузка...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead className="data-table__head">
|
||||||
<tr>
|
<tr className="data-table__row">
|
||||||
<th>Банк</th>
|
<th className="data-table__head-cell">Банк</th>
|
||||||
<th>Номер счёта</th>
|
<th className="data-table__head-cell">Номер счёта</th>
|
||||||
<th>Валюта</th>
|
<th className="data-table__head-cell">Валюта</th>
|
||||||
<th>Алиас</th>
|
<th className="data-table__head-cell">Алиас</th>
|
||||||
<th></th>
|
<th className="data-table__head-cell"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="data-table__body">
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
<tr key={a.id}>
|
<tr className="data-table__row" key={a.id}>
|
||||||
<td>{a.bank}</td>
|
<td className="data-table__cell">{a.bank}</td>
|
||||||
<td>{a.accountNumberMasked}</td>
|
<td className="data-table__cell">{a.accountNumberMasked}</td>
|
||||||
<td>{a.currency}</td>
|
<td className="data-table__cell">{a.currency}</td>
|
||||||
<td>
|
<td className="data-table__cell">
|
||||||
{editingId === a.id ? (
|
{editingId === a.id ? (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -72,21 +72,21 @@ export function AccountsList() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
a.alias || (
|
a.alias || (
|
||||||
<span className="text-muted">не задан</span>
|
<span className="text text--muted">не задан</span>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="data-table__cell">
|
||||||
{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)}
|
||||||
>
|
>
|
||||||
Изменить
|
Изменить
|
||||||
@@ -104,8 +104,8 @@ export function AccountsList() {
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{accounts.length === 0 && (
|
{accounts.length === 0 && (
|
||||||
<tr>
|
<tr className="data-table__row">
|
||||||
<td colSpan={5} className="td-center text-muted">
|
<td colSpan={5} className="data-table__cell data-table__cell--center text text--muted">
|
||||||
Нет счетов. Импортируйте выписку.
|
Нет счетов. Импортируйте выписку.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -21,24 +21,24 @@ export function CategoriesList() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="section-loading">Загрузка...</div>;
|
return <div className="state state--loading">Загрузка...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead className="data-table__head">
|
||||||
<tr>
|
<tr className="data-table__row">
|
||||||
<th>Категория</th>
|
<th className="data-table__head-cell">Категория</th>
|
||||||
<th>Тип</th>
|
<th className="data-table__head-cell">Тип</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="data-table__body">
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<tr key={c.id}>
|
<tr className="data-table__row" key={c.id}>
|
||||||
<td>{c.name}</td>
|
<td className="data-table__cell">{c.name}</td>
|
||||||
<td>
|
<td className="data-table__cell">
|
||||||
<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>
|
||||||
|
|||||||
@@ -14,9 +14,14 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
|
'var(--color-chart-1)', 'var(--color-chart-2)',
|
||||||
'#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1',
|
'var(--color-chart-3)', 'var(--color-chart-4)',
|
||||||
'#14b8a6', '#e11d48', '#0ea5e9', '#a855f7', '#22c55e',
|
'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', {
|
const rubFormatter = new Intl.NumberFormat('ru-RU', {
|
||||||
@@ -30,7 +35,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 +46,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 +63,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,21 +74,21 @@ export function CategoryChart({ data }: Props) {
|
|||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|
||||||
<table className="category-table">
|
<table className="category-chart__table">
|
||||||
<thead>
|
<thead className="category-chart__head">
|
||||||
<tr>
|
<tr className="category-chart__row">
|
||||||
<th>Категория</th>
|
<th className="category-chart__head-cell">Категория</th>
|
||||||
<th>Сумма</th>
|
<th className="category-chart__head-cell">Сумма</th>
|
||||||
<th className="th-center">Операций</th>
|
<th className="category-chart__head-cell category-chart__head-cell--center">Операций</th>
|
||||||
<th className="th-center">Доля</th>
|
<th className="category-chart__head-cell category-chart__head-cell--center">Доля</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="category-chart__body">
|
||||||
{data.map((item, idx) => (
|
{data.map((item, idx) => (
|
||||||
<tr key={item.categoryId}>
|
<tr className="category-chart__row" key={item.categoryId}>
|
||||||
<td>
|
<td className="category-chart__cell">
|
||||||
<span
|
<span
|
||||||
className="color-dot"
|
className="category-chart__dot"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
COLORS[idx % COLORS.length],
|
COLORS[idx % COLORS.length],
|
||||||
@@ -91,9 +96,9 @@ export function CategoryChart({ data }: Props) {
|
|||||||
/>
|
/>
|
||||||
{item.categoryName}
|
{item.categoryName}
|
||||||
</td>
|
</td>
|
||||||
<td>{formatAmount(item.amount)}</td>
|
<td className="category-chart__cell">{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>
|
||||||
|
|||||||
@@ -32,29 +32,29 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="modal-overlay"
|
className="modal"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
<div className="modal__dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal__header">
|
||||||
<h2>Очистить историю операций</h2>
|
<h2 className="modal__title">Очистить историю операций</h2>
|
||||||
<button className="btn-close" onClick={onClose}>
|
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||||
×
|
×
|
||||||
</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 field--flush">
|
||||||
<label>
|
<label className="field__label field__label--checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={check1}
|
checked={check1}
|
||||||
@@ -64,8 +64,8 @@ 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 field--flush">
|
||||||
<label>
|
<label className="field__label field__label--checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={check2}
|
checked={check2}
|
||||||
@@ -77,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 modal__action"
|
||||||
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 modal__action" onClick={onClose}>
|
||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,40 +34,40 @@ 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 className="data-section__title">История импортов</h3>
|
||||||
<p className="section-desc">
|
<p className="data-section__description">
|
||||||
Список импортов выписок. Можно удалить операции конкретного импорта.
|
Список импортов выписок. Можно удалить операции конкретного импорта.
|
||||||
</p>
|
</p>
|
||||||
{imports.length === 0 ? (
|
{imports.length === 0 ? (
|
||||||
<p className="muted">Импортов пока нет.</p>
|
<p className="text text--muted">Импортов пока нет.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-responsive">
|
<div className="table-shell">
|
||||||
<table className="table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead className="data-table__head">
|
||||||
<tr>
|
<tr className="data-table__row">
|
||||||
<th>Дата</th>
|
<th className="data-table__head-cell">Дата</th>
|
||||||
<th>Счёт</th>
|
<th className="data-table__head-cell">Счёт</th>
|
||||||
<th>Банк</th>
|
<th className="data-table__head-cell">Банк</th>
|
||||||
<th>Импортировано</th>
|
<th className="data-table__head-cell">Импортировано</th>
|
||||||
<th>Дубликаты</th>
|
<th className="data-table__head-cell">Дубликаты</th>
|
||||||
<th></th>
|
<th className="data-table__head-cell"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="data-table__body">
|
||||||
{imports.map((imp) => (
|
{imports.map((imp) => (
|
||||||
<tr key={imp.id}>
|
<tr className="data-table__row" key={imp.id}>
|
||||||
<td>{formatDate(imp.importedAt)}</td>
|
<td className="data-table__cell">{formatDate(imp.importedAt)}</td>
|
||||||
<td>
|
<td className="data-table__cell">
|
||||||
{imp.accountAlias || imp.accountNumberMasked || '—'}
|
{imp.accountAlias || imp.accountNumberMasked || '—'}
|
||||||
</td>
|
</td>
|
||||||
<td>{imp.bank}</td>
|
<td className="data-table__cell">{imp.bank}</td>
|
||||||
<td>{imp.importedCount}</td>
|
<td className="data-table__cell">{imp.importedCount}</td>
|
||||||
<td>{imp.duplicatesSkipped}</td>
|
<td className="data-table__cell">{imp.duplicatesSkipped}</td>
|
||||||
<td>
|
<td className="data-table__cell">
|
||||||
<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 className="data-section__title">Очистка данных</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)}
|
||||||
>
|
>
|
||||||
Очистить историю
|
Очистить историю
|
||||||
|
|||||||
@@ -32,40 +32,40 @@ export function DeleteImportModal({ imp, onClose, onDone }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="modal-overlay"
|
className="modal"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
<div className="modal__dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal__header">
|
||||||
<h2>Удалить импорт</h2>
|
<h2 className="modal__title">Удалить импорт</h2>
|
||||||
<button className="btn-close" onClick={onClose}>
|
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||||
×
|
×
|
||||||
</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 modal__action"
|
||||||
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 modal__action" onClick={onClose}>
|
||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,48 +83,48 @@ export function EditTransactionModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="modal-overlay"
|
className="modal"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
<div className="modal__dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal__header">
|
||||||
<h2>Редактирование операции</h2>
|
<h2 className="modal__title">Редактирование операции</h2>
|
||||||
<button className="btn-close" onClick={onClose}>
|
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form className="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>
|
||||||
{transaction.commission !== 0 && (
|
{transaction.commission !== 0 && (
|
||||||
<div className="modal-tx-row">
|
<div className="transaction-preview__row">
|
||||||
<span className="modal-tx-label">Комиссия</span>
|
<span className="transaction-preview__label">Комиссия</span>
|
||||||
<span>{formatAmount(getCommissionAmountSigned(transaction))}</span>
|
<span>{formatAmount(getCommissionAmountSigned(transaction))}</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 className="modal-tx-description">
|
<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 className="field__label" htmlFor="edit-category">Категория</label>
|
||||||
<select
|
<select
|
||||||
id="edit-category"
|
id="edit-category"
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
@@ -139,8 +139,8 @@ export function EditTransactionModal({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="field">
|
||||||
<label htmlFor="edit-comment">Комментарий</label>
|
<label className="field__label" htmlFor="edit-comment">Комментарий</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="edit-comment"
|
id="edit-comment"
|
||||||
rows={2}
|
rows={2}
|
||||||
@@ -150,10 +150,10 @@ export function EditTransactionModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-divider" />
|
<div className="form__divider" />
|
||||||
|
|
||||||
<div className="form-group form-group-checkbox">
|
<div className="field field--checkbox">
|
||||||
<label>
|
<label className="field__label field__label--checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={createRule}
|
checked={createRule}
|
||||||
@@ -165,8 +165,8 @@ export function EditTransactionModal({
|
|||||||
|
|
||||||
{createRule && (
|
{createRule && (
|
||||||
<>
|
<>
|
||||||
<div className="form-group">
|
<div className="field">
|
||||||
<label htmlFor="edit-pattern">
|
<label className="field__label" htmlFor="edit-pattern">
|
||||||
Шаблон (ключевая строка)
|
Шаблон (ключевая строка)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -177,8 +177,8 @@ export function EditTransactionModal({
|
|||||||
maxLength={200}
|
maxLength={200}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group form-group-checkbox">
|
<div className="field field--checkbox">
|
||||||
<label>
|
<label className="field__label field__label--checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={requiresConfirmation}
|
checked={requiresConfirmation}
|
||||||
@@ -193,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 modal__action"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
className="button button--primary modal__action"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
|||||||
@@ -60,71 +60,73 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="modal-overlay"
|
className="modal"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
<div className="modal__dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal__header">
|
||||||
<h2>Импорт выписки</h2>
|
<h2 className="modal__title">Импорт выписки</h2>
|
||||||
<button className="btn-close" onClick={onClose}>
|
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||||
×
|
×
|
||||||
</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">
|
||||||
<p>Выберите файл выписки (PDF или JSON, формат 1.0)</p>
|
<p className="import-upload__description">
|
||||||
|
Выберите файл выписки (PDF или JSON, формат 1.0)
|
||||||
|
</p>
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
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 className="import-result__title">Импорт завершён</h3>
|
||||||
<table className="import-stats">
|
<table className="import-result__stats">
|
||||||
<tbody>
|
<tbody className="import-result__stats-body">
|
||||||
<tr>
|
<tr className="import-result__stat-row">
|
||||||
<td>Счёт</td>
|
<td className="import-result__stat-cell import-result__stat-cell--label">Счёт</td>
|
||||||
<td>{result.accountNumberMasked}</td>
|
<td className="import-result__stat-cell import-result__stat-cell--value">{result.accountNumberMasked}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr className="import-result__stat-row">
|
||||||
<td>Новый счёт</td>
|
<td className="import-result__stat-cell import-result__stat-cell--label">Новый счёт</td>
|
||||||
<td>{result.isNewAccount ? 'Да' : 'Нет'}</td>
|
<td className="import-result__stat-cell import-result__stat-cell--value">{result.isNewAccount ? 'Да' : 'Нет'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr className="import-result__stat-row">
|
||||||
<td>Импортировано</td>
|
<td className="import-result__stat-cell import-result__stat-cell--label">Импортировано</td>
|
||||||
<td>{result.imported}</td>
|
<td className="import-result__stat-cell import-result__stat-cell--value">{result.imported}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr className="import-result__stat-row">
|
||||||
<td>Дубликатов пропущено</td>
|
<td className="import-result__stat-cell import-result__stat-cell--label">Дубликатов пропущено</td>
|
||||||
<td>{result.duplicatesSkipped}</td>
|
<td className="import-result__stat-cell import-result__stat-cell--value">{result.duplicatesSkipped}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr className="import-result__stat-row">
|
||||||
<td>Всего в файле</td>
|
<td className="import-result__stat-cell import-result__stat-cell--label">Всего в файле</td>
|
||||||
<td>{result.totalInFile}</td>
|
<td className="import-result__stat-cell import-result__stat-cell--value">{result.totalInFile}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{result.isNewAccount && !aliasSaved && (
|
{result.isNewAccount && !aliasSaved && (
|
||||||
<div className="import-alias">
|
<div className="import-result__alias">
|
||||||
<label>Алиас для нового счёта</label>
|
<label className="import-result__alias-label">Алиас для нового счёта</label>
|
||||||
<div className="import-alias-row">
|
<div className="import-result__alias-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Напр.: Текущий, Накопительный"
|
placeholder="Напр.: Текущий, Накопительный"
|
||||||
@@ -133,7 +135,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()}
|
||||||
>
|
>
|
||||||
@@ -144,7 +146,7 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{aliasSaved && (
|
{aliasSaved && (
|
||||||
<div className="import-alias-saved">
|
<div className="import-result__alias-saved">
|
||||||
Алиас сохранён
|
Алиас сохранён
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -152,14 +154,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 modal__action" onClick={onDone}>
|
||||||
Готово
|
Готово
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="button button--secondary modal__action"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,27 +33,27 @@ 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}
|
||||||
>
|
>
|
||||||
<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" />
|
<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" />
|
<polyline points="14,2 14,8 20,8" />
|
||||||
<line x1="16" y1="13" x2="8" y2="13" />
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
@@ -66,11 +66,11 @@ 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}
|
||||||
>
|
>
|
||||||
<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="18" y1="20" x2="18" y2="10" />
|
||||||
<line x1="12" y1="20" x2="12" y2="4" />
|
<line x1="12" y1="20" x2="12" y2="4" />
|
||||||
<line x1="6" y1="20" x2="6" y2="14" />
|
<line x1="6" y1="20" x2="6" y2="14" />
|
||||||
@@ -81,11 +81,11 @@ 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}
|
||||||
>
|
>
|
||||||
<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" />
|
<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" />
|
<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>
|
</svg>
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="Предыдущая страница"
|
||||||
>
|
>
|
||||||
←
|
←
|
||||||
</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="Следующая страница"
|
||||||
>
|
>
|
||||||
→
|
→
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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="Предыдущий период"
|
||||||
|
>
|
||||||
←
|
←
|
||||||
</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">—</span>
|
<span className="period-picker__separator">—</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="Следующий период"
|
||||||
|
>
|
||||||
→
|
→
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -51,56 +51,59 @@ export function RulesList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="section-loading">Загрузка...</div>;
|
return <div className="state state--loading">Загрузка...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="rules-list settings-section">
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead className="data-table__head">
|
||||||
<tr>
|
<tr className="data-table__row">
|
||||||
<th>Шаблон</th>
|
<th className="data-table__head-cell">Шаблон</th>
|
||||||
<th>Категория</th>
|
<th className="data-table__head-cell">Категория</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 className="data-table__head-cell">Создано</th>
|
||||||
<th className="th-center">Активно</th>
|
<th className="data-table__head-cell data-table__head-cell--center">Активно</th>
|
||||||
<th></th>
|
<th className="data-table__head-cell"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="data-table__body">
|
||||||
{rules.map((r) => (
|
{rules.map((r) => (
|
||||||
<tr
|
<tr
|
||||||
key={r.id}
|
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>
|
<code>{r.pattern}</code>
|
||||||
</td>
|
</td>
|
||||||
<td>{r.categoryName}</td>
|
<td className="data-table__cell">{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 className="data-table__cell">
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
@@ -117,8 +120,8 @@ export function RulesList() {
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{rules.length === 0 && (
|
{rules.length === 0 && (
|
||||||
<tr>
|
<tr className="data-table__row">
|
||||||
<td colSpan={7} className="td-center text-muted">
|
<td colSpan={7} className="data-table__cell data-table__cell--center text text--muted">
|
||||||
Нет правил
|
Нет правил
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -6,59 +6,63 @@ 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__card summary__card--investments">
|
||||||
<div className="summary-label">На инвестиции</div>
|
<div className="summary__label">Движение ДС</div>
|
||||||
<div className="summary-value">
|
<div className="summary__value">
|
||||||
{formatAmount(summary.investmentOutflow)}
|
{formatAmount(summary.cashNet)}
|
||||||
</div>
|
</div>
|
||||||
{summary.investmentIncomeExcluded > 0 && (
|
<div className="summary__subvalue">Поступило: {formatAmount(summary.cashInflow)}</div>
|
||||||
<div className="summary-subvalue">
|
<div className="summary__subvalue">Списано: {formatAmount(summary.cashOutflow)}</div>
|
||||||
Исключено из доходов: {formatAmount(summary.investmentIncomeExcluded)}
|
{(summary.transferInflow > 0 || summary.transferOutflow > 0) && (
|
||||||
|
<div className="summary__subvalue">
|
||||||
|
Переводы: {formatAmount(summary.transferInflow)} / {formatAmount(summary.transferOutflow)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
|||||||
@@ -26,20 +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,
|
Переводы: item.transferOutflow / 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="var(--color-border)" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="period"
|
dataKey="period"
|
||||||
tickFormatter={(v: string) => {
|
tickFormatter={(v: string) => {
|
||||||
@@ -47,32 +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="var(--color-text-secondary)"
|
||||||
|
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="var(--color-text-secondary)"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number) => rubFormatter.format(value)}
|
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 />
|
<Legend />
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="Расходы"
|
dataKey="Расходы"
|
||||||
fill="#ef4444"
|
fill="var(--color-danger)"
|
||||||
radius={[4, 4, 0, 0]}
|
radius={[4, 4, 0, 0]}
|
||||||
/>
|
/>
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="Доходы"
|
dataKey="Доходы"
|
||||||
fill="#10b981"
|
fill="var(--color-success)"
|
||||||
radius={[4, 4, 0, 0]}
|
radius={[4, 4, 0, 0]}
|
||||||
/>
|
/>
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="Инвестиции"
|
dataKey="Переводы"
|
||||||
fill="#f59e0b"
|
fill="var(--color-warning)"
|
||||||
radius={[4, 4, 0, 0]}
|
radius={[4, 4, 0, 0]}
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
|
|||||||
@@ -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 className="field__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="Предыдущий период"
|
||||||
>
|
>
|
||||||
←
|
←
|
||||||
</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">—</span>
|
<span className="filters__separator">—</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="Следующий период"
|
||||||
>
|
>
|
||||||
→
|
→
|
||||||
</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,8 +178,8 @@ export function TransactionFilters({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="filter-group">
|
<div className="field">
|
||||||
<label>Счёт</label>
|
<label className="field__label">Счёт</label>
|
||||||
<select
|
<select
|
||||||
value={filters.accountId}
|
value={filters.accountId}
|
||||||
onChange={(e) => set('accountId', e.target.value)}
|
onChange={(e) => set('accountId', e.target.value)}
|
||||||
@@ -191,8 +193,8 @@ export function TransactionFilters({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="filter-group">
|
<div className="field">
|
||||||
<label>Тип</label>
|
<label className="field__label">Тип</label>
|
||||||
<select
|
<select
|
||||||
value={filters.direction}
|
value={filters.direction}
|
||||||
onChange={(e) => set('direction', e.target.value)}
|
onChange={(e) => set('direction', e.target.value)}
|
||||||
@@ -204,8 +206,8 @@ export function TransactionFilters({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="filter-group">
|
<div className="field">
|
||||||
<label>Категория</label>
|
<label className="field__label">Категория</label>
|
||||||
<select
|
<select
|
||||||
value={filters.categoryId}
|
value={filters.categoryId}
|
||||||
onChange={(e) => set('categoryId', e.target.value)}
|
onChange={(e) => set('categoryId', e.target.value)}
|
||||||
@@ -220,9 +222,9 @@ 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 className="field__label">Поиск</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Поиск по описанию..."
|
placeholder="Поиск по описанию..."
|
||||||
@@ -231,8 +233,8 @@ export function TransactionFilters({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="filter-group">
|
<div className="field">
|
||||||
<label>Сумма от (₽)</label>
|
<label className="field__label">Сумма от (₽)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="мин"
|
placeholder="мин"
|
||||||
@@ -241,8 +243,8 @@ export function TransactionFilters({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="filter-group">
|
<div className="field">
|
||||||
<label>Сумма до (₽)</label>
|
<label className="field__label">Сумма до (₽)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="макс"
|
placeholder="макс"
|
||||||
@@ -251,8 +253,8 @@ export function TransactionFilters({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="filter-group filter-group-checkbox">
|
<div className="field field--checkbox">
|
||||||
<label>
|
<label className="field__label field__label--checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={filters.onlyUnconfirmed}
|
checked={filters.onlyUnconfirmed}
|
||||||
@@ -262,9 +264,9 @@ export function TransactionFilters({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="filter-group">
|
<div className="field">
|
||||||
<label>Сортировка</label>
|
<label className="field__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>
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ 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 {
|
function getCommissionAmountSigned(tx: Transaction): number {
|
||||||
@@ -33,39 +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>
|
||||||
{tx.commission !== 0 && (
|
{tx.commission !== 0 && (
|
||||||
<div className="transaction-card-commission">
|
<div className="transaction-card__commission">
|
||||||
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
|
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="transaction-card-body">
|
<div className="transaction-card__body">
|
||||||
<span className="description-text">{tx.description}</span>
|
<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="Категория не подтверждена"
|
||||||
>
|
>
|
||||||
?
|
?
|
||||||
@@ -73,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">
|
||||||
@@ -90,81 +91,82 @@ 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 className="data-table__head">
|
||||||
<tr>
|
<tr className="data-table__row">
|
||||||
<th>Дата</th>
|
<th className="data-table__head-cell">Дата</th>
|
||||||
<th>Счёт</th>
|
<th className="data-table__head-cell">Счёт</th>
|
||||||
<th>Сумма</th>
|
<th className="data-table__head-cell">Сумма</th>
|
||||||
<th>Описание</th>
|
<th className="data-table__head-cell">Описание</th>
|
||||||
<th>Категория</th>
|
<th className="data-table__head-cell">Категория</th>
|
||||||
<th className="th-center">Статус</th>
|
<th className="data-table__head-cell data-table__head-cell--center">Статус</th>
|
||||||
<th></th>
|
<th className="data-table__head-cell"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="data-table__body">
|
||||||
{transactions.map((tx) => (
|
{transactions.map((tx) => (
|
||||||
<tr
|
<tr
|
||||||
key={tx.id}
|
key={tx.id}
|
||||||
className={
|
className={
|
||||||
!tx.isCategoryConfirmed && tx.categoryId
|
!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)}
|
{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] ?? ''}`}
|
||||||
>
|
>
|
||||||
<div>{formatAmount(tx.amountSigned)}</div>
|
<div>{formatAmount(tx.amountSigned)}</div>
|
||||||
{tx.commission !== 0 && (
|
{tx.commission !== 0 && (
|
||||||
<div className="td-commission">
|
<div className="data-table__subtext">
|
||||||
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
|
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
|
||||||
</div>
|
</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="text text--muted">—</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="Категория не подтверждена"
|
||||||
>
|
>
|
||||||
?
|
?
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="data-table__cell">
|
||||||
<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">
|
||||||
@@ -179,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} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -88,15 +88,18 @@ 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 className="field__label">Счёт</label>
|
||||||
<select
|
<select
|
||||||
value={accountId}
|
value={accountId}
|
||||||
onChange={(e) => setAccountId(e.target.value)}
|
onChange={(e) => setAccountId(e.target.value)}
|
||||||
@@ -109,8 +112,8 @@ export function AnalyticsPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="filter-group filter-group-checkbox">
|
<div className="field field--checkbox">
|
||||||
<label>
|
<label className="field__label field__label--checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={onlyConfirmed}
|
checked={onlyConfirmed}
|
||||||
@@ -123,17 +126,17 @@ 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 className="chart-card__title">Динамика</h3>
|
||||||
<TimeseriesChart data={timeseries} />
|
<TimeseriesChart data={timeseries} />
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<h3>По категориям</h3>
|
<h3 className="chart-card__title">По категориям</h3>
|
||||||
<CategoryChart data={byCategory} />
|
<CategoryChart data={byCategory} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 page__action"
|
||||||
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>
|
||||||
|
|||||||
@@ -20,17 +20,17 @@ 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 className="login__title">Семейный бюджет</h1>
|
||||||
<p>Войдите для продолжения</p>
|
<p className="login__subtitle">Войдите для продолжения</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="login-form">
|
<form onSubmit={handleSubmit} className="form 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 className="field__label" htmlFor="login">Логин</label>
|
||||||
<input
|
<input
|
||||||
id="login"
|
id="login"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -41,8 +41,8 @@ export function LoginPage() {
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="field">
|
||||||
<label htmlFor="password">Пароль</label>
|
<label className="field__label" htmlFor="password">Пароль</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="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 ? 'Вход...' : 'Войти'}
|
||||||
|
|||||||
@@ -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
2
package-lock.json
generated
@@ -64,7 +64,7 @@
|
|||||||
},
|
},
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"name": "@family-budget/frontend",
|
"name": "@family-budget/frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@family-budget/shared": "*",
|
"@family-budget/shared": "*",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/shared",
|
"name": "@family-budget/shared",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ export interface AnalyticsSummaryResponse {
|
|||||||
totalExpense: number;
|
totalExpense: number;
|
||||||
totalIncome: number;
|
totalIncome: number;
|
||||||
net: number;
|
net: number;
|
||||||
investmentOutflow: number;
|
cashInflow: number;
|
||||||
investmentIncomeExcluded: number;
|
cashOutflow: number;
|
||||||
|
transferInflow: number;
|
||||||
|
transferOutflow: number;
|
||||||
|
cashNet: number;
|
||||||
topCategories: TopCategory[];
|
topCategories: TopCategory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,5 +55,5 @@ export interface TimeseriesItem {
|
|||||||
periodEnd: string;
|
periodEnd: string;
|
||||||
expenseAmount: number;
|
expenseAmount: number;
|
||||||
incomeAmount: number;
|
incomeAmount: number;
|
||||||
investmentOutflow: number;
|
transferOutflow: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user