Compare commits

..

7 Commits

31 changed files with 481 additions and 386 deletions

1
.gitignore vendored
View File

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

15
CHANGELOG.md Normal file
View File

@@ -0,0 +1,15 @@
# Changelog
## [Frontend 0.9.0 / Backend 0.6.2] - 2026-08-19
### Changed
- Analytics now calculate net income and expenses by category type: refunds reduce the original expense category and internal transfers no longer affect P&L.
- Added a separate cash-flow summary with incoming, outgoing, and transfer amounts.
- Added unit and transactional SQL checks for analytics calculations.
## [0.8.7] - 2026-08-16
### Changed
- Completed the frontend CSS class migration to BEM naming and aligned component styles with the design system.

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/backend",
"version": "0.5.12",
"version": "0.6.2",
"private": true,
"scripts": {
"dev": "tsx watch src/app.ts",
@@ -8,6 +8,8 @@
"start": "node dist/app.js",
"migrate": "tsx src/db/migrate.ts",
"migrate:prod": "node dist/db/migrate.js",
"test:analytics": "tsx src/services/analyticsSemantics.test.ts",
"test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts",
"test:llm": "tsx src/scripts/testLlm.ts"
},
"dependencies": {

View File

@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { pool } from '../db/pool';
import { getByCategory, getSummary, getTimeseries } from './analytics';
async function testQueries(): Promise<void> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const account = await client.query("INSERT INTO accounts (bank, account_number, currency) VALUES ('TEST', 'analytics-test-1', 'RUB') RETURNING id");
const otherAccount = await client.query("INSERT INTO accounts (bank, account_number, currency) VALUES ('TEST', 'analytics-test-2', 'RUB') RETURNING id");
const categories = await client.query("INSERT INTO categories (name, type) VALUES ('Тест расход', 'expense'), ('Тест доход', 'income'), ('Тест перевод', 'transfer') RETURNING id, type");
const accountId = Number(account.rows[0].id);
const otherAccountId = Number(otherAccount.rows[0].id);
const categoryId = Object.fromEntries(categories.rows.map((row) => [row.type, Number(row.id)]));
const insert = (account: number, at: string, amount: number, category: number, fingerprint: string) => client.query(
"INSERT INTO transactions (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) VALUES ($1, $2, $3, 0, 'test', CASE WHEN $3 < 0 THEN 'expense' ELSE 'income' END, $4, $5, TRUE)",
[account, at, amount, fingerprint, category],
);
await insert(accountId, '2026-07-01T12:00:00+03:00', -10_000, categoryId.expense, 'analytics-test-1');
await insert(accountId, '2026-07-02T12:00:00+03:00', 4_000, categoryId.expense, 'analytics-test-2');
await insert(accountId, '2026-07-03T12:00:00+03:00', 20_000, categoryId.income, 'analytics-test-3');
await insert(accountId, '2026-07-04T12:00:00+03:00', -5_000, categoryId.income, 'analytics-test-4');
await insert(accountId, '2026-07-05T12:00:00+03:00', -3_000, categoryId.transfer, 'analytics-test-5');
await insert(otherAccountId, '2026-07-01T12:00:00+03:00', -99_000, categoryId.expense, 'analytics-test-6');
await insert(accountId, '2026-08-01T12:00:00+03:00', -7_000, categoryId.expense, 'analytics-test-7');
const params = { from: '2026-07-01', to: '2026-07-31', accountId, onlyConfirmed: true };
const summary = await getSummary(params, client);
assert.deepEqual({ expense: summary.totalExpense, income: summary.totalIncome, net: summary.net, transferOut: summary.transferOutflow }, { expense: 6_000, income: 15_000, net: 9_000, transferOut: 3_000 });
assert.equal((await getSummary({ ...params, from: '2026-08-01', to: '2026-08-31' }, client)).totalExpense, 7_000);
assert.deepEqual((await getByCategory(params, client)).map((item) => item.amount), [6_000]);
const timeseries = await getTimeseries({ ...params, granularity: 'month' }, client);
assert.equal(timeseries[0].expenseAmount, 6_000);
assert.equal(timeseries[0].incomeAmount, 15_000);
} finally {
await client.query('ROLLBACK');
client.release();
}
}
testQueries().then(() => console.log('analytics SQL: OK')).finally(() => pool.end());

View File

@@ -1,4 +1,5 @@
import { pool } from '../db/pool';
import { effectiveAmountCase } from './analyticsSemantics';
import type {
AnalyticsSummaryResponse,
TopCategory,
@@ -7,6 +8,8 @@ import type {
Granularity,
} from '@family-budget/shared';
type Queryable = Pick<typeof pool, 'query'>;
interface BaseParams {
from: string;
to: string;
@@ -14,6 +17,24 @@ interface BaseParams {
onlyConfirmed?: boolean;
}
const effectiveAmount = effectiveAmountCase('t');
function analyticsTransactions(where: string): string {
return `
WITH analytics_transactions AS (
SELECT
t.id,
t.operation_at,
COALESCE(c.type, t.direction) AS analytic_type,
COALESCE(t.category_id, 0) AS category_id,
COALESCE(c.name, 'Без категории') AS category_name,
${effectiveAmount} AS effective_amount
FROM transactions t
LEFT JOIN categories c ON c.id = t.category_id
${where}
)`;
}
function buildBaseConditions(
params: BaseParams,
startIdx: number,
@@ -42,96 +63,45 @@ function buildBaseConditions(
return { conditions, values, nextIdx: idx };
}
export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryResponse> {
export async function getSummary(
params: BaseParams,
db: Queryable = pool,
): Promise<AnalyticsSummaryResponse> {
const { conditions, values } = buildBaseConditions(params, 1);
const where = 'WHERE ' + conditions.join(' AND ');
const totalsResult = await pool.query(
`WITH investment_ref AS (
SELECT (
SELECT id
FROM categories
WHERE name = 'Инвестиции' AND type = 'transfer'
ORDER BY id ASC
LIMIT 1
) AS investment_category_id
const totalsResult = await db.query(
`${analyticsTransactions(where)},
category_net AS (
SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount
FROM analytics_transactions
GROUP BY category_id, category_name, analytic_type
)
SELECT
COALESCE(SUM(
CASE
WHEN (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)
THEN ABS(t.amount_signed) + t.commission
ELSE 0
END
), 0)::bigint AS total_expense,
COALESCE(SUM(
CASE
WHEN t.direction = 'income'
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)
THEN t.amount_signed + t.commission
ELSE 0
END
), 0)::bigint AS total_income,
COALESCE(SUM(
CASE
WHEN t.amount_signed < 0
AND ir.investment_category_id IS NOT NULL
AND t.category_id = ir.investment_category_id
THEN ABS(t.amount_signed) + t.commission
ELSE 0
END
), 0)::bigint AS investment_outflow,
COALESCE(SUM(
CASE
WHEN t.amount_signed > 0
AND ir.investment_category_id IS NOT NULL
AND t.category_id = ir.investment_category_id
THEN t.amount_signed + t.commission
ELSE 0
END
), 0)::bigint AS investment_income_excluded
FROM transactions t
CROSS JOIN investment_ref ir
${where}`,
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'expense'), 0)::bigint AS total_expense,
COALESCE(SUM(GREATEST(amount, 0)) FILTER (WHERE analytic_type = 'income'), 0)::bigint AS total_income,
COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_inflow,
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions), 0)::bigint AS cash_outflow,
COALESCE((SELECT SUM(GREATEST(effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_inflow,
COALESCE((SELECT SUM(GREATEST(-effective_amount, 0)) FROM analytics_transactions WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow
FROM category_net`,
values,
);
const totalExpense = Number(totalsResult.rows[0].total_expense);
const totalIncome = Number(totalsResult.rows[0].total_income);
const investmentOutflow = Number(totalsResult.rows[0].investment_outflow);
const investmentIncomeExcluded = Number(totalsResult.rows[0].investment_income_excluded);
const cashInflow = Number(totalsResult.rows[0].cash_inflow);
const cashOutflow = Number(totalsResult.rows[0].cash_outflow);
const transferInflow = Number(totalsResult.rows[0].transfer_inflow);
const transferOutflow = Number(totalsResult.rows[0].transfer_outflow);
const topResult = await pool.query(
`WITH investment_ref AS (
SELECT (
SELECT id
FROM categories
WHERE name = 'Инвестиции' AND type = 'transfer'
ORDER BY id ASC
LIMIT 1
) AS investment_category_id
)
SELECT
COALESCE(t.category_id, 0)::bigint AS category_id,
COALESCE(c.name, 'Без категории') AS category_name,
SUM(ABS(t.amount_signed) + t.commission)::bigint AS amount
FROM transactions t
CROSS JOIN investment_ref ir
LEFT JOIN categories c ON c.id = t.category_id
${where}
AND (t.direction = 'expense' 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, 'Без категории')
const topResult = await db.query(
`${analyticsTransactions(where)}
SELECT category_id::bigint, category_name, GREATEST(-SUM(effective_amount), 0)::bigint AS amount
FROM analytics_transactions
WHERE analytic_type = 'expense'
GROUP BY category_id, category_name
HAVING SUM(effective_amount) < 0
ORDER BY amount DESC
LIMIT 5`,
values,
@@ -148,68 +118,39 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
totalExpense,
totalIncome,
net: totalIncome - totalExpense,
investmentOutflow,
investmentIncomeExcluded,
cashInflow,
cashOutflow,
transferInflow,
transferOutflow,
cashNet: cashInflow - cashOutflow,
topCategories,
};
}
export async function getByCategory(params: BaseParams): Promise<ByCategoryItem[]> {
export async function getByCategory(
params: BaseParams,
db: Queryable = pool,
): Promise<ByCategoryItem[]> {
const { conditions, values } = buildBaseConditions(params, 1);
const where = 'WHERE ' + conditions.join(' AND ');
const totalResult = await pool.query(
`WITH investment_ref AS (
SELECT (
SELECT id
FROM categories
WHERE name = 'Инвестиции' AND type = 'transfer'
ORDER BY id ASC
LIMIT 1
) AS investment_category_id
)
SELECT COALESCE(SUM(ABS(t.amount_signed) + t.commission), 0)::bigint AS total
FROM transactions t
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
)
const { rows } = await db.query(
`${analyticsTransactions(where)}
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,
category_id::bigint,
category_name,
GREATEST(-SUM(effective_amount), 0)::bigint AS amount,
COUNT(*)::int AS tx_count
FROM transactions t
CROSS JOIN investment_ref ir
LEFT JOIN categories c ON c.id = t.category_id
${where}
AND (t.direction = 'expense' 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, 'Без категории')
FROM analytics_transactions
WHERE analytic_type = 'expense'
GROUP BY category_id, category_name
HAVING SUM(effective_amount) < 0
ORDER BY amount DESC`,
values,
);
const total = rows.reduce((sum, row) => sum + Number(row.amount), 0);
return rows.map((r) => ({
categoryId: r.category_id != null ? Number(r.category_id) : 0,
categoryName: r.category_name ?? 'Без категории',
@@ -221,6 +162,7 @@ export async function getByCategory(params: BaseParams): Promise<ByCategoryItem[
export async function getTimeseries(
params: BaseParams & { categoryId?: number; granularity: Granularity },
db: Queryable = pool,
): Promise<TimeseriesItem[]> {
let truncExpr: string;
let intervalStr: string;
@@ -265,61 +207,38 @@ export async function getTimeseries(
const txWhere = txConditions.join(' AND ');
const { rows } = await pool.query(
const { rows } = await db.query(
`WITH periods AS (
SELECT
gs::date AS period_start,
${periodEndExpr} AS period_end
FROM generate_series(${truncExpr}, $2::date, '${intervalStr}'::interval) gs
),
investment_ref AS (
SELECT (
SELECT id
FROM categories
WHERE name = 'Инвестиции' AND type = 'transfer'
ORDER BY id ASC
LIMIT 1
) AS investment_category_id
period_transactions AS (
SELECT
p.period_start,
p.period_end,
COALESCE(c.type, t.direction) AS analytic_type,
COALESCE(t.category_id, 0) AS category_id,
${effectiveAmount} AS effective_amount
FROM periods p
LEFT JOIN transactions t ON ${txWhere}
LEFT JOIN categories c ON c.id = t.category_id
),
period_category_net AS (
SELECT period_start, period_end, analytic_type, category_id, SUM(effective_amount)::bigint AS amount
FROM period_transactions
GROUP BY period_start, period_end, analytic_type, category_id
)
SELECT
p.period_start,
p.period_end,
COALESCE(SUM(
CASE
WHEN (t.direction = 'expense' OR (t.direction = 'transfer' AND t.amount_signed < 0))
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)
THEN ABS(t.amount_signed) + t.commission
ELSE 0
END
), 0)::bigint AS expense_amount,
COALESCE(SUM(
CASE
WHEN t.direction = 'income'
AND (
ir.investment_category_id IS NULL
OR t.category_id IS DISTINCT FROM ir.investment_category_id
)
THEN t.amount_signed + t.commission
ELSE 0
END
), 0)::bigint AS income_amount,
COALESCE(SUM(
CASE
WHEN t.amount_signed < 0
AND ir.investment_category_id IS NOT NULL
AND t.category_id = ir.investment_category_id
THEN ABS(t.amount_signed) + t.commission
ELSE 0
END
), 0)::bigint AS investment_outflow
FROM periods p
CROSS JOIN investment_ref ir
LEFT JOIN transactions t ON ${txWhere}
GROUP BY p.period_start, p.period_end
ORDER BY p.period_start`,
period_start,
period_end,
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'expense'), 0)::bigint AS expense_amount,
COALESCE(SUM(GREATEST(amount, 0)) FILTER (WHERE analytic_type = 'income'), 0)::bigint AS income_amount,
COALESCE(SUM(GREATEST(-amount, 0)) FILTER (WHERE analytic_type = 'transfer'), 0)::bigint AS transfer_outflow
FROM period_category_net
GROUP BY period_start, period_end
ORDER BY period_start`,
values,
);
@@ -328,6 +247,6 @@ export async function getTimeseries(
periodEnd: r.period_end.toISOString().slice(0, 10),
expenseAmount: Number(r.expense_amount),
incomeAmount: Number(r.income_amount),
investmentOutflow: Number(r.investment_outflow),
transferOutflow: Number(r.transfer_outflow),
}));
}

View File

@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import { getEffectiveAmount, getNetAmount } from './analyticsSemantics';
assert.equal(getNetAmount('expense', [
{ amountSigned: -10_000, commission: 0 },
{ amountSigned: 4_000, commission: 0 },
]), 6_000, 'возврат уменьшает расход своей категории');
assert.equal(getNetAmount('income', [
{ amountSigned: 20_000, commission: 0 },
{ amountSigned: -5_000, commission: 0 },
]), 15_000, 'корректировка уменьшает доход');
assert.equal(getNetAmount('transfer', [
{ amountSigned: -10_000, commission: 0 },
{ amountSigned: 10_000, commission: 0 },
]), 0, 'перевод не является доходом или расходом');
assert.equal(
getEffectiveAmount({ amountSigned: 0, commission: 500, description: 'Зачисление кэшбэка' }),
500,
'кэшбэк учитывается как поступление',
);
console.log('analytics semantics: OK');

View File

@@ -0,0 +1,32 @@
export type AnalyticType = 'expense' | 'income' | 'transfer';
interface AmountInput {
amountSigned: number;
commission: number;
description?: string;
}
export function getEffectiveAmount({ amountSigned, commission, description = '' }: AmountInput): number {
if (amountSigned === 0 && commission > 0 && description.toLowerCase().includes('зачисление')) {
return commission;
}
return amountSigned < 0 ? amountSigned - commission : amountSigned + commission;
}
export function getNetAmount(type: AnalyticType, amounts: AmountInput[]): number {
const amount = amounts.reduce((sum, item) => sum + getEffectiveAmount(item), 0);
if (type === 'expense') return Math.max(-amount, 0);
if (type === 'income') return Math.max(amount, 0);
return 0;
}
export function effectiveAmountCase(alias: string): string {
return `
CASE
WHEN ${alias}.amount_signed = 0 AND ${alias}.commission > 0
AND ${alias}.description ILIKE '%зачисление%'
THEN ${alias}.commission
WHEN ${alias}.amount_signed < 0 THEN ${alias}.amount_signed - ${alias}.commission
ELSE ${alias}.amount_signed + ${alias}.commission
END`;
}

View File

@@ -37,6 +37,26 @@
- Чистый результат (`net = totalIncome - totalExpense`).
- Топ-35 категорий по расходам (сумма, доля).
#### Нетто-правила
Тип назначенной категории определяет экономический смысл операции:
- `expense`: отрицательная сумма увеличивает расход, положительная сумма
(возврат) уменьшает его; в отчёте категория не может стать отрицательным
расходом;
- `income`: положительная сумма увеличивает доход, отрицательная уменьшает
его;
- `transfer`: не учитывается в реальных доходах и расходах.
Если возврат не удалось определить правилом, пользователь вручную назначает
ему ту же расходную категорию, что у исходной покупки. Отдельная категория
«Возврат» и связывание двух операций не требуются.
Для контроля остатков сводка отдельно показывает движение денежных средств:
входящий и исходящий поток, внутренние переводы и чистое изменение денег.
Переводы между картой, накопительными и инвестиционными счетами остаются в
этом блоке, но не искажают доходы и расходы.
Реализуется через эндпоинт `GET /api/analytics/summary`.
Параметры:
@@ -52,6 +72,11 @@
"totalExpense": 12345600,
"totalIncome": 20000000,
"net": 7654400,
"cashInflow": 26000000,
"cashOutflow": 18345600,
"transferInflow": 5000000,
"transferOutflow": 5000000,
"cashNet": 7654400,
"topCategories": [
{ "categoryId": 1, "categoryName": "Продукты", "amount": 4500000, "share": 0.36 },
{ "categoryId": 2, "categoryName": "ЖКХ", "amount": 2500000, "share": 0.20 }

View File

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

View File

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

View File

@@ -42,22 +42,22 @@ export function AccountsList() {
return (
<div className="settings-section">
<table className="data-table">
<thead>
<tr>
<th>Банк</th>
<th>Номер счёта</th>
<th>Валюта</th>
<th>Алиас</th>
<th></th>
<thead className="data-table__head">
<tr className="data-table__row">
<th className="data-table__head-cell">Банк</th>
<th className="data-table__head-cell">Номер счёта</th>
<th className="data-table__head-cell">Валюта</th>
<th className="data-table__head-cell">Алиас</th>
<th className="data-table__head-cell"></th>
</tr>
</thead>
<tbody>
<tbody className="data-table__body">
{accounts.map((a) => (
<tr key={a.id}>
<td>{a.bank}</td>
<td>{a.accountNumberMasked}</td>
<td>{a.currency}</td>
<td>
<tr className="data-table__row" key={a.id}>
<td className="data-table__cell">{a.bank}</td>
<td className="data-table__cell">{a.accountNumberMasked}</td>
<td className="data-table__cell">{a.currency}</td>
<td className="data-table__cell">
{editingId === a.id ? (
<input
type="text"
@@ -72,11 +72,11 @@ export function AccountsList() {
/>
) : (
a.alias || (
<span className="muted-text">не задан</span>
<span className="text text--muted">не задан</span>
)
)}
</td>
<td>
<td className="data-table__cell">
{editingId === a.id ? (
<div className="button-group">
<button
@@ -104,8 +104,8 @@ export function AccountsList() {
</tr>
))}
{accounts.length === 0 && (
<tr>
<td colSpan={5} className="data-table__cell data-table__cell--center muted-text">
<tr className="data-table__row">
<td colSpan={5} className="data-table__cell data-table__cell--center text text--muted">
Нет счетов. Импортируйте выписку.
</td>
</tr>

View File

@@ -27,17 +27,17 @@ export function CategoriesList() {
return (
<div className="settings-section">
<table className="data-table">
<thead>
<tr>
<th>Категория</th>
<th>Тип</th>
<thead className="data-table__head">
<tr className="data-table__row">
<th className="data-table__head-cell">Категория</th>
<th className="data-table__head-cell">Тип</th>
</tr>
</thead>
<tbody>
<tbody className="data-table__body">
{categories.map((c) => (
<tr key={c.id}>
<td>{c.name}</td>
<td>
<tr className="data-table__row" key={c.id}>
<td className="data-table__cell">{c.name}</td>
<td className="data-table__cell">
<span className={`badge badge--${c.type}`}>
{TYPE_LABELS[c.type] ?? c.type}
</span>

View File

@@ -14,9 +14,14 @@ interface Props {
}
const COLORS = [
'#2563eb', '#e85d3f', '#0f9f7f', '#d89b17', '#7c5cdb',
'#c8558f', '#1495a3', '#79a92f', '#e06d2f', '#4f6fd7',
'#1c9a8a', '#d43d5c', '#277bbd', '#9b59b6', '#2f9f63',
'var(--color-chart-1)', 'var(--color-chart-2)',
'var(--color-chart-3)', 'var(--color-chart-4)',
'var(--color-chart-5)', 'var(--color-chart-6)',
'var(--color-chart-7)', 'var(--color-chart-8)',
'var(--color-chart-9)', 'var(--color-chart-10)',
'var(--color-chart-11)', 'var(--color-chart-12)',
'var(--color-chart-13)', 'var(--color-chart-14)',
'var(--color-chart-15)',
];
const rubFormatter = new Intl.NumberFormat('ru-RU', {
@@ -70,18 +75,18 @@ export function CategoryChart({ data }: Props) {
</ResponsiveContainer>
<table className="category-chart__table">
<thead>
<tr>
<th>Категория</th>
<th>Сумма</th>
<th className="category-chart__cell category-chart__cell--center">Операций</th>
<th className="category-chart__cell category-chart__cell--center">Доля</th>
<thead className="category-chart__head">
<tr className="category-chart__row">
<th className="category-chart__head-cell">Категория</th>
<th className="category-chart__head-cell">Сумма</th>
<th className="category-chart__head-cell category-chart__head-cell--center">Операций</th>
<th className="category-chart__head-cell category-chart__head-cell--center">Доля</th>
</tr>
</thead>
<tbody>
<tbody className="category-chart__body">
{data.map((item, idx) => (
<tr key={item.categoryId}>
<td>
<tr className="category-chart__row" key={item.categoryId}>
<td className="category-chart__cell">
<span
className="category-chart__dot"
style={{
@@ -91,7 +96,7 @@ export function CategoryChart({ data }: Props) {
/>
{item.categoryName}
</td>
<td>{formatAmount(item.amount)}</td>
<td className="category-chart__cell">{formatAmount(item.amount)}</td>
<td className="category-chart__cell category-chart__cell--center">{item.txCount}</td>
<td className="category-chart__cell category-chart__cell--center">
{(item.share * 100).toFixed(1)}%

View File

@@ -32,14 +32,14 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
return (
<div
className="modal-backdrop"
className="modal"
onMouseDown={(e) => {
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">
<h2>Очистить историю операций</h2>
<h2 className="modal__title">Очистить историю операций</h2>
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
&times;
</button>
@@ -53,8 +53,8 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
{error && <div className="alert alert--error">{error}</div>}
<div className="field field--checkbox danger-note__check">
<label>
<div className="field field--checkbox field--flush">
<label className="field__label field__label--checkbox">
<input
type="checkbox"
checked={check1}
@@ -64,8 +64,8 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
</label>
</div>
<div className="field field--checkbox danger-note__check">
<label>
<div className="field field--checkbox field--flush">
<label className="field__label field__label--checkbox">
<input
type="checkbox"
checked={check2}
@@ -79,13 +79,13 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
<div className="modal__footer">
<button
className="button button--danger"
className="button button--danger modal__action"
onClick={handleConfirm}
disabled={!canConfirm || loading}
>
{loading ? 'Удаление…' : 'Удалить всё'}
</button>
<button className="button button--secondary" onClick={onClose}>
<button className="button button--secondary modal__action" onClick={onClose}>
Отмена
</button>
</div>

View File

@@ -35,36 +35,36 @@ export function DataSection() {
return (
<div className="data-section">
<div className="data-section__block">
<h3>История импортов</h3>
<h3 className="data-section__title">История импортов</h3>
<p className="data-section__description">
Список импортов выписок. Можно удалить операции конкретного импорта.
</p>
{imports.length === 0 ? (
<p className="muted-text">Импортов пока нет.</p>
<p className="text text--muted">Импортов пока нет.</p>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Дата</th>
<th>Счёт</th>
<th>Банк</th>
<th>Импортировано</th>
<th>Дубликаты</th>
<th></th>
<thead className="data-table__head">
<tr className="data-table__row">
<th className="data-table__head-cell">Дата</th>
<th className="data-table__head-cell">Счёт</th>
<th className="data-table__head-cell">Банк</th>
<th className="data-table__head-cell">Импортировано</th>
<th className="data-table__head-cell">Дубликаты</th>
<th className="data-table__head-cell"></th>
</tr>
</thead>
<tbody>
<tbody className="data-table__body">
{imports.map((imp) => (
<tr key={imp.id}>
<td>{formatDate(imp.importedAt)}</td>
<td>
<tr className="data-table__row" key={imp.id}>
<td className="data-table__cell">{formatDate(imp.importedAt)}</td>
<td className="data-table__cell">
{imp.accountAlias || imp.accountNumberMasked || '—'}
</td>
<td>{imp.bank}</td>
<td>{imp.importedCount}</td>
<td>{imp.duplicatesSkipped}</td>
<td>
<td className="data-table__cell">{imp.bank}</td>
<td className="data-table__cell">{imp.importedCount}</td>
<td className="data-table__cell">{imp.duplicatesSkipped}</td>
<td className="data-table__cell">
<button
type="button"
className="button button--danger button--small"
@@ -83,7 +83,7 @@ export function DataSection() {
</div>
<div className="data-section__block">
<h3>Очистка данных</h3>
<h3 className="data-section__title">Очистка данных</h3>
<p className="data-section__description">
Очистить историю операций (все транзакции). Счета, категории и
правила сохранятся.

View File

@@ -32,14 +32,14 @@ export function DeleteImportModal({ imp, onClose, onDone }: Props) {
return (
<div
className="modal-backdrop"
className="modal"
onMouseDown={(e) => {
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">
<h2>Удалить импорт</h2>
<h2 className="modal__title">Удалить импорт</h2>
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
&times;
</button>
@@ -59,13 +59,13 @@ export function DeleteImportModal({ imp, onClose, onDone }: Props) {
<div className="modal__footer">
<button
type="button"
className="button button--danger"
className="button button--danger modal__action"
onClick={handleConfirm}
disabled={loading}
>
{loading ? 'Удаление…' : 'Удалить'}
</button>
<button type="button" className="button button--secondary" onClick={onClose}>
<button type="button" className="button button--secondary modal__action" onClick={onClose}>
Отмена
</button>
</div>

View File

@@ -83,20 +83,20 @@ export function EditTransactionModal({
return (
<div
className="modal-backdrop"
className="modal"
onMouseDown={(e) => {
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">
<h2>Редактирование операции</h2>
<h2 className="modal__title">Редактирование операции</h2>
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
&times;
</button>
</div>
<form onSubmit={handleSubmit}>
<form className="form" onSubmit={handleSubmit}>
<div className="modal__body">
{error && <div className="alert alert--error">{error}</div>}
@@ -124,7 +124,7 @@ export function EditTransactionModal({
</div>
<div className="field">
<label htmlFor="edit-category">Категория</label>
<label className="field__label" htmlFor="edit-category">Категория</label>
<select
id="edit-category"
value={categoryId}
@@ -140,7 +140,7 @@ export function EditTransactionModal({
</div>
<div className="field">
<label htmlFor="edit-comment">Комментарий</label>
<label className="field__label" htmlFor="edit-comment">Комментарий</label>
<textarea
id="edit-comment"
rows={2}
@@ -150,10 +150,10 @@ export function EditTransactionModal({
/>
</div>
<div className="form-divider" />
<div className="form__divider" />
<div className="field field--checkbox">
<label>
<label className="field__label field__label--checkbox">
<input
type="checkbox"
checked={createRule}
@@ -166,7 +166,7 @@ export function EditTransactionModal({
{createRule && (
<>
<div className="field">
<label htmlFor="edit-pattern">
<label className="field__label" htmlFor="edit-pattern">
Шаблон (ключевая строка)
</label>
<input
@@ -178,7 +178,7 @@ export function EditTransactionModal({
/>
</div>
<div className="field field--checkbox">
<label>
<label className="field__label field__label--checkbox">
<input
type="checkbox"
checked={requiresConfirmation}
@@ -196,14 +196,14 @@ export function EditTransactionModal({
<div className="modal__footer">
<button
type="button"
className="button button--secondary"
className="button button--secondary modal__action"
onClick={onClose}
>
Отмена
</button>
<button
type="submit"
className="button button--primary"
className="button button--primary modal__action"
disabled={saving}
>
{saving ? 'Сохранение...' : 'Сохранить'}

View File

@@ -60,14 +60,14 @@ export function ImportModal({ onClose, onDone }: Props) {
return (
<div
className="modal-backdrop"
className="modal"
onMouseDown={(e) => {
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">
<h2>Импорт выписки</h2>
<h2 className="modal__title">Импорт выписки</h2>
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
&times;
</button>
@@ -78,7 +78,9 @@ export function ImportModal({ onClose, onDone }: Props) {
{!result && (
<div className="import-upload">
<p>Выберите файл выписки (PDF или JSON, формат 1.0)</p>
<p className="import-upload__description">
Выберите файл выписки (PDF или JSON, формат 1.0)
</p>
<input
ref={fileRef}
type="file"
@@ -95,35 +97,35 @@ export function ImportModal({ onClose, onDone }: Props) {
{result && (
<div className="import-result">
<div className="import-result__icon" aria-hidden="true"></div>
<h3>Импорт завершён</h3>
<h3 className="import-result__title">Импорт завершён</h3>
<table className="import-result__stats">
<tbody>
<tr>
<td>Счёт</td>
<td>{result.accountNumberMasked}</td>
<tbody className="import-result__stats-body">
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Счёт</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.accountNumberMasked}</td>
</tr>
<tr>
<td>Новый счёт</td>
<td>{result.isNewAccount ? 'Да' : 'Нет'}</td>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Новый счёт</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.isNewAccount ? 'Да' : 'Нет'}</td>
</tr>
<tr>
<td>Импортировано</td>
<td>{result.imported}</td>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Импортировано</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.imported}</td>
</tr>
<tr>
<td>Дубликатов пропущено</td>
<td>{result.duplicatesSkipped}</td>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Дубликатов пропущено</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.duplicatesSkipped}</td>
</tr>
<tr>
<td>Всего в файле</td>
<td>{result.totalInFile}</td>
<tr className="import-result__stat-row">
<td className="import-result__stat-cell import-result__stat-cell--label">Всего в файле</td>
<td className="import-result__stat-cell import-result__stat-cell--value">{result.totalInFile}</td>
</tr>
</tbody>
</table>
{result.isNewAccount && !aliasSaved && (
<div className="import-result__alias">
<label>Алиас для нового счёта</label>
<label className="import-result__alias-label">Алиас для нового счёта</label>
<div className="import-result__alias-row">
<input
type="text"
@@ -154,12 +156,12 @@ export function ImportModal({ onClose, onDone }: Props) {
<div className="modal__footer">
{result ? (
<button className="button button--primary" onClick={onDone}>
<button className="button button--primary modal__action" onClick={onDone}>
Готово
</button>
) : (
<button
className="button button--secondary"
className="button button--secondary modal__action"
onClick={onClose}
disabled={loading}
>

View File

@@ -53,7 +53,7 @@ export function Layout({ children }: { children: ReactNode }) {
}
onClick={closeDrawer}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg className="sidebar__nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14,2 14,8 20,8" />
<line x1="16" y1="13" x2="8" y2="13" />
@@ -70,7 +70,7 @@ export function Layout({ children }: { children: ReactNode }) {
}
onClick={closeDrawer}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg className="sidebar__nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
@@ -85,7 +85,7 @@ export function Layout({ children }: { children: ReactNode }) {
}
onClick={closeDrawer}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg className="sidebar__nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>

View File

@@ -55,29 +55,29 @@ export function RulesList() {
}
return (
<div className="settings-section">
<div className="rules-list settings-section">
<table className="data-table">
<thead>
<tr>
<th>Шаблон</th>
<th>Категория</th>
<thead className="data-table__head">
<tr className="data-table__row">
<th className="data-table__head-cell">Шаблон</th>
<th className="data-table__head-cell">Категория</th>
<th className="data-table__head-cell data-table__head-cell--center">Приоритет</th>
<th className="data-table__head-cell data-table__head-cell--center">Подтверждение</th>
<th>Создано</th>
<th className="data-table__head-cell">Создано</th>
<th className="data-table__head-cell data-table__head-cell--center">Активно</th>
<th></th>
<th className="data-table__head-cell"></th>
</tr>
</thead>
<tbody>
<tbody className="data-table__body">
{rules.map((r) => (
<tr
key={r.id}
className={!r.isActive ? 'data-table__row--inactive' : ''}
className={`data-table__row ${!r.isActive ? 'data-table__row--inactive' : ''}`}
>
<td>
<td className="data-table__cell">
<code>{r.pattern}</code>
</td>
<td>{r.categoryName}</td>
<td className="data-table__cell">{r.categoryName}</td>
<td className="data-table__cell data-table__cell--center">{r.priority}</td>
<td className="data-table__cell data-table__cell--center">
{r.requiresConfirmation ? 'Да' : 'Нет'}
@@ -99,7 +99,7 @@ export function RulesList() {
{r.isActive ? 'Вкл' : 'Выкл'}
</button>
</td>
<td>
<td className="data-table__cell">
<div className="rules-list__actions">
{r.isActive && (
<button
@@ -120,8 +120,8 @@ export function RulesList() {
</tr>
))}
{rules.length === 0 && (
<tr>
<td colSpan={7} className="data-table__cell data-table__cell--center muted-text">
<tr className="data-table__row">
<td colSpan={7} className="data-table__cell data-table__cell--center text text--muted">
Нет правил
</td>
</tr>

View File

@@ -34,13 +34,15 @@ export function SummaryCards({ summary }: Props) {
</div>
<div className="summary__card summary__card--investments">
<div className="summary__label">На инвестиции</div>
<div className="summary__label">Движение ДС</div>
<div className="summary__value">
{formatAmount(summary.investmentOutflow)}
{formatAmount(summary.cashNet)}
</div>
{summary.investmentIncomeExcluded > 0 && (
<div className="summary__subvalue">Поступило: {formatAmount(summary.cashInflow)}</div>
<div className="summary__subvalue">Списано: {formatAmount(summary.cashOutflow)}</div>
{(summary.transferInflow > 0 || summary.transferOutflow > 0) && (
<div className="summary__subvalue">
Исключено из доходов: {formatAmount(summary.investmentIncomeExcluded)}
Переводы: {formatAmount(summary.transferInflow)} / {formatAmount(summary.transferOutflow)}
</div>
)}
</div>

View File

@@ -33,13 +33,13 @@ export function TimeseriesChart({ data }: Props) {
period: item.periodStart,
Расходы: Math.abs(item.expenseAmount) / 100,
Доходы: item.incomeAmount / 100,
Инвестиции: Math.abs(item.investmentOutflow) / 100,
Переводы: item.transferOutflow / 100,
}));
return (
<ResponsiveContainer width="100%" height={chartHeight}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e7ded2" vertical={false} />
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" vertical={false} />
<XAxis
dataKey="period"
tickFormatter={(v: string) => {
@@ -47,7 +47,7 @@ export function TimeseriesChart({ data }: Props) {
return `${d.getDate()}.${String(d.getMonth() + 1).padStart(2, '0')}`;
}}
fontSize={12}
stroke="#7d7164"
stroke="var(--color-text-secondary)"
tickLine={false}
axisLine={false}
/>
@@ -56,33 +56,33 @@ export function TimeseriesChart({ data }: Props) {
v >= 1000 ? `${(v / 1000).toFixed(0)}к` : String(v)
}
fontSize={12}
stroke="#7d7164"
stroke="var(--color-text-secondary)"
tickLine={false}
axisLine={false}
/>
<Tooltip
formatter={(value: number) => rubFormatter.format(value)}
cursor={{ fill: 'rgba(129, 93, 58, 0.08)' }}
cursor={{ fill: 'var(--color-chart-cursor)' }}
contentStyle={{
border: '1px solid #e7ded2',
border: '1px solid var(--color-border)',
borderRadius: 8,
boxShadow: '0 16px 36px rgba(54, 42, 30, 0.14)',
boxShadow: 'var(--shadow-lg)',
}}
/>
<Legend />
<Bar
dataKey="Расходы"
fill="#e85d3f"
fill="var(--color-danger)"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="Доходы"
fill="#0f9f7f"
fill="var(--color-success)"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="Инвестиции"
fill="#d89b17"
dataKey="Переводы"
fill="var(--color-warning)"
radius={[4, 4, 0, 0]}
/>
</BarChart>

View File

@@ -118,7 +118,7 @@ export function TransactionFilters({
<div className="filters">
<div className="filters__row">
<div className="field field--period">
<label>Период</label>
<label className="field__label">Период</label>
<div className="filters__date-control">
{filters.periodMode !== 'custom' && (
<button
@@ -179,7 +179,7 @@ export function TransactionFilters({
</div>
<div className="field">
<label>Счёт</label>
<label className="field__label">Счёт</label>
<select
value={filters.accountId}
onChange={(e) => set('accountId', e.target.value)}
@@ -194,7 +194,7 @@ export function TransactionFilters({
</div>
<div className="field">
<label>Тип</label>
<label className="field__label">Тип</label>
<select
value={filters.direction}
onChange={(e) => set('direction', e.target.value)}
@@ -207,7 +207,7 @@ export function TransactionFilters({
</div>
<div className="field">
<label>Категория</label>
<label className="field__label">Категория</label>
<select
value={filters.categoryId}
onChange={(e) => set('categoryId', e.target.value)}
@@ -224,7 +224,7 @@ export function TransactionFilters({
<div className="filters__row">
<div className="field field--wide">
<label>Поиск</label>
<label className="field__label">Поиск</label>
<input
type="text"
placeholder="Поиск по описанию..."
@@ -234,7 +234,7 @@ export function TransactionFilters({
</div>
<div className="field">
<label>Сумма от ()</label>
<label className="field__label">Сумма от ()</label>
<input
type="number"
placeholder="мин"
@@ -244,7 +244,7 @@ export function TransactionFilters({
</div>
<div className="field">
<label>Сумма до ()</label>
<label className="field__label">Сумма до ()</label>
<input
type="number"
placeholder="макс"
@@ -254,7 +254,7 @@ export function TransactionFilters({
</div>
<div className="field field--checkbox">
<label>
<label className="field__label field__label--checkbox">
<input
type="checkbox"
checked={filters.onlyUnconfirmed}
@@ -265,7 +265,7 @@ export function TransactionFilters({
</div>
<div className="field">
<label>Сортировка</label>
<label className="field__label">Сортировка</label>
<div className="filters__sort">
<select
value={filters.sortBy}

View File

@@ -102,25 +102,25 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
<>
<div className="table-shell table-shell--desktop">
<table className="data-table">
<thead>
<tr>
<th>Дата</th>
<th>Счёт</th>
<th>Сумма</th>
<th>Описание</th>
<th>Категория</th>
<thead className="data-table__head">
<tr className="data-table__row">
<th className="data-table__head-cell">Дата</th>
<th className="data-table__head-cell">Счёт</th>
<th className="data-table__head-cell">Сумма</th>
<th className="data-table__head-cell">Описание</th>
<th className="data-table__head-cell">Категория</th>
<th className="data-table__head-cell data-table__head-cell--center">Статус</th>
<th></th>
<th className="data-table__head-cell"></th>
</tr>
</thead>
<tbody>
<tbody className="data-table__body">
{transactions.map((tx) => (
<tr
key={tx.id}
className={
!tx.isCategoryConfirmed && tx.categoryId
? 'data-table__row--unconfirmed'
: ''
? 'data-table__row data-table__row--unconfirmed'
: 'data-table__row'
}
>
<td className="data-table__cell data-table__cell--nowrap">
@@ -149,7 +149,7 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
</td>
<td className="data-table__cell data-table__cell--nowrap">
{tx.categoryName || (
<span className="muted-text"></span>
<span className="text text--muted"></span>
)}
</td>
<td className="data-table__cell data-table__cell--center">
@@ -162,7 +162,7 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
</span>
)}
</td>
<td>
<td className="data-table__cell">
<button
className="icon-button"
onClick={() => onEdit(tx)}

View File

@@ -99,7 +99,7 @@ export function AnalyticsPage() {
<PeriodSelector period={period} onChange={setPeriod} />
<div className="analytics-panel__filters">
<div className="field">
<label>Счёт</label>
<label className="field__label">Счёт</label>
<select
value={accountId}
onChange={(e) => setAccountId(e.target.value)}
@@ -113,7 +113,7 @@ export function AnalyticsPage() {
</select>
</div>
<div className="field field--checkbox">
<label>
<label className="field__label field__label--checkbox">
<input
type="checkbox"
checked={onlyConfirmed}
@@ -132,11 +132,11 @@ export function AnalyticsPage() {
{summary && <SummaryCards summary={summary} />}
<div className="analytics-grid">
<div className="chart-card">
<h3>Динамика</h3>
<h3 className="chart-card__title">Динамика</h3>
<TimeseriesChart data={timeseries} />
</div>
<div className="chart-card">
<h3>По категориям</h3>
<h3 className="chart-card__title">По категориям</h3>
<CategoryChart data={byCategory} />
</div>
</div>

View File

@@ -210,7 +210,7 @@ export function HistoryPage() {
<h1 className="page__title">История операций</h1>
</div>
<button
className="button button--primary"
className="button button--primary page__action"
onClick={() => setShowImport(true)}
>
<svg className="button__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">

View File

@@ -24,13 +24,13 @@ export function LoginPage() {
<div className="login__panel">
<div className="login__header">
<span className="login__icon"></span>
<h1>Семейный бюджет</h1>
<p>Войдите для продолжения</p>
<h1 className="login__title">Семейный бюджет</h1>
<p className="login__subtitle">Войдите для продолжения</p>
</div>
<form onSubmit={handleSubmit} className="login__form">
<form onSubmit={handleSubmit} className="form login__form">
{error && <div className="alert alert--error">{error}</div>}
<div className="field">
<label htmlFor="login">Логин</label>
<label className="field__label" htmlFor="login">Логин</label>
<input
id="login"
type="text"
@@ -42,7 +42,7 @@ export function LoginPage() {
/>
</div>
<div className="field">
<label htmlFor="password">Пароль</label>
<label className="field__label" htmlFor="password">Пароль</label>
<input
id="password"
type="password"

View File

@@ -31,6 +31,22 @@
--color-warning-soft: #fff6da;
--color-transfer: #7c5cdb;
--color-transfer-soft: #f0ebff;
--color-chart-1: var(--color-primary);
--color-chart-2: var(--color-danger);
--color-chart-3: var(--color-success);
--color-chart-4: var(--color-warning);
--color-chart-5: var(--color-transfer);
--color-chart-6: #c8558f;
--color-chart-7: #1495a3;
--color-chart-8: #79a92f;
--color-chart-9: #e06d2f;
--color-chart-10: #4f6fd7;
--color-chart-11: #1c9a8a;
--color-chart-12: #d43d5c;
--color-chart-13: #277bbd;
--color-chart-14: #9b59b6;
--color-chart-15: #2f9f63;
--color-chart-cursor: rgba(129, 93, 58, 0.08);
--color-sidebar: #fff9f0;
--radius-sm: 4px;
--radius: 8px;
@@ -182,7 +198,7 @@ button {
box-shadow var(--transition);
}
.sidebar__nav-link svg {
.sidebar__nav-icon {
flex: 0 0 auto;
}
@@ -315,7 +331,7 @@ button {
line-height: 1.15;
}
.muted-text {
.text--muted {
color: var(--color-text-muted);
}
@@ -344,13 +360,13 @@ button {
padding-bottom: 4px;
}
.field label {
.field__label {
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 800;
}
.field--checkbox label {
.field__label--checkbox {
display: flex;
align-items: center;
gap: 8px;
@@ -594,7 +610,7 @@ input[type="checkbox"] {
color: #b8321f;
}
.form-divider {
.form__divider {
height: 1px;
background: var(--color-border);
margin: 4px 0;
@@ -644,12 +660,12 @@ input[type="checkbox"] {
box-shadow: 0 18px 34px rgba(232, 93, 63, 0.22);
}
.login__header h1 {
.login__title {
font-size: 24px;
font-weight: 850;
}
.login__header p {
.login__subtitle {
color: var(--color-text-secondary);
margin-top: 4px;
}
@@ -836,7 +852,7 @@ input[type="checkbox"] {
padding: 20px;
}
.chart-card h3 {
.chart-card__title {
margin-bottom: 14px;
font-size: 16px;
font-weight: 850;
@@ -853,19 +869,20 @@ input[type="checkbox"] {
border-collapse: collapse;
}
.category-chart__table th,
.category-chart__table td {
.category-chart__head-cell,
.category-chart__cell {
border-bottom: 1px solid var(--color-border);
padding: 7px 9px;
text-align: left;
}
.category-chart__table th {
.category-chart__head-cell {
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 850;
}
.category-chart__head-cell--center,
.category-chart__cell--center {
text-align: center !important;
}
@@ -897,15 +914,14 @@ input[type="checkbox"] {
border-collapse: collapse;
}
.data-table th,
.data-table td {
.data-table__head-cell,
.data-table__cell {
border-bottom: 1px solid var(--color-border);
padding: 11px 14px;
text-align: left;
vertical-align: middle;
}
.data-table th,
.data-table__head-cell {
color: var(--color-text-secondary);
font-size: 12px;
@@ -915,15 +931,15 @@ input[type="checkbox"] {
white-space: nowrap;
}
.data-table tbody tr {
.data-table__row {
transition: background var(--transition);
}
.data-table tbody tr:hover {
.data-table__body .data-table__row:hover {
background: rgba(247, 242, 235, 0.72);
}
.data-table tbody tr:last-child td {
.data-table__body .data-table__row:last-child .data-table__cell {
border-bottom: 0;
}
@@ -1128,7 +1144,7 @@ input[type="checkbox"] {
padding: 22px;
}
.data-section__block h3 {
.data-section__title {
margin-bottom: 6px;
font-size: 16px;
font-weight: 850;
@@ -1177,7 +1193,7 @@ input[type="checkbox"] {
Modals and import
================================================================ */
.modal-backdrop {
.modal {
position: fixed;
inset: 0;
z-index: 200;
@@ -1188,7 +1204,7 @@ input[type="checkbox"] {
background: rgba(35, 27, 21, 0.44);
}
.modal {
.modal__dialog {
width: min(100%, 540px);
max-height: 90vh;
overflow-y: auto;
@@ -1206,7 +1222,7 @@ input[type="checkbox"] {
border-bottom: 1px solid var(--color-border);
}
.modal__header h2 {
.modal__title {
font-size: 18px;
font-weight: 850;
}
@@ -1281,7 +1297,7 @@ input[type="checkbox"] {
text-align: center;
}
.import-upload p {
.import-upload__description {
color: var(--color-text-secondary);
}
@@ -1314,7 +1330,7 @@ input[type="checkbox"] {
font-weight: 900;
}
.import-result h3 {
.import-result__title {
margin-bottom: 14px;
font-size: 17px;
font-weight: 850;
@@ -1326,16 +1342,16 @@ input[type="checkbox"] {
text-align: left;
}
.import-result__stats td {
.import-result__stat-cell {
border-bottom: 1px solid var(--color-border);
padding: 7px 10px;
}
.import-result__stats td:first-child {
.import-result__stat-cell--label {
color: var(--color-text-secondary);
}
.import-result__stats td:last-child {
.import-result__stat-cell--value {
font-variant-numeric: tabular-nums;
font-weight: 850;
text-align: right;
@@ -1346,7 +1362,7 @@ input[type="checkbox"] {
text-align: left;
}
.import-result__alias label {
.import-result__alias-label {
display: block;
margin-bottom: 6px;
color: var(--color-text-secondary);
@@ -1374,7 +1390,7 @@ input[type="checkbox"] {
padding: 12px 14px;
}
.danger-note__check {
.field--flush {
padding-bottom: 0;
}
@@ -1436,7 +1452,7 @@ input[type="checkbox"] {
flex-direction: column;
}
.page__header .button {
.page__action {
width: 100%;
}
@@ -1504,12 +1520,12 @@ input[type="checkbox"] {
padding: 28px 22px;
}
.modal-backdrop {
.modal {
align-items: stretch;
padding: 0;
}
.modal {
.modal__dialog {
display: flex;
width: 100%;
max-height: none;
@@ -1527,7 +1543,7 @@ input[type="checkbox"] {
flex-wrap: wrap;
}
.modal__footer .button {
.modal__action {
flex: 1 1 150px;
}

2
package-lock.json generated
View File

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

View File

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

View File

@@ -18,8 +18,11 @@ export interface AnalyticsSummaryResponse {
totalExpense: number;
totalIncome: number;
net: number;
investmentOutflow: number;
investmentIncomeExcluded: number;
cashInflow: number;
cashOutflow: number;
transferInflow: number;
transferOutflow: number;
cashNet: number;
topCategories: TopCategory[];
}
@@ -52,5 +55,5 @@ export interface TimeseriesItem {
periodEnd: string;
expenseAmount: number;
incomeAmount: number;
investmentOutflow: number;
transferOutflow: number;
}