feat: complete analytics category filtering

This commit is contained in:
2026-08-21 00:08:59 +03:00
parent efc9854064
commit 24b8ed8261
9 changed files with 37 additions and 6 deletions

View File

@@ -1,5 +1,11 @@
# Changelog # Changelog
## [Frontend 0.11.0 / Backend 0.10.0 / Shared 0.5.0] - 2026-08-21
### Added
- Added category filtering to analytics summary and category breakdown, including savings-account interest handling in the filtered results.
## [Backend 0.9.2] - 2026-08-20 ## [Backend 0.9.2] - 2026-08-20
### Added ### Added

View File

@@ -1,6 +1,6 @@
{ {
"name": "@family-budget/backend", "name": "@family-budget/backend",
"version": "0.9.2", "version": "0.10.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tsx watch src/app.ts", "dev": "tsx watch src/app.ts",

View File

@@ -8,7 +8,7 @@ const router = Router();
router.get( router.get(
'/summary', '/summary',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { from, to, accountId, onlyConfirmed } = req.query; const { from, to, accountId, categoryId, onlyConfirmed } = req.query;
if (!from || !to) { if (!from || !to) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'from and to are required' }); res.status(400).json({ error: 'BAD_REQUEST', message: 'from and to are required' });
return; return;
@@ -18,6 +18,7 @@ router.get(
from: from as string, from: from as string,
to: to as string, to: to as string,
accountId: accountId ? Number(accountId) : undefined, accountId: accountId ? Number(accountId) : undefined,
categoryId: categoryId ? Number(categoryId) : undefined,
onlyConfirmed: onlyConfirmed === 'true', onlyConfirmed: onlyConfirmed === 'true',
}); });
res.json(result); res.json(result);
@@ -27,7 +28,7 @@ router.get(
router.get( router.get(
'/by-category', '/by-category',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { from, to, accountId, onlyConfirmed } = req.query; const { from, to, accountId, categoryId, onlyConfirmed } = req.query;
if (!from || !to) { if (!from || !to) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'from and to are required' }); res.status(400).json({ error: 'BAD_REQUEST', message: 'from and to are required' });
return; return;
@@ -37,6 +38,7 @@ router.get(
from: from as string, from: from as string,
to: to as string, to: to as string,
accountId: accountId ? Number(accountId) : undefined, accountId: accountId ? Number(accountId) : undefined,
categoryId: categoryId ? Number(categoryId) : undefined,
onlyConfirmed: onlyConfirmed === 'true', onlyConfirmed: onlyConfirmed === 'true',
}); });
res.json(result); res.json(result);

View File

@@ -32,6 +32,8 @@ async function testQueries(): Promise<void> {
const params = { from: '2026-07-01', to: '2026-07-31', accountId, onlyConfirmed: true }; const params = { from: '2026-07-01', to: '2026-07-31', accountId, onlyConfirmed: true };
const summary = await getSummary(params, client); const summary = await getSummary(params, client);
assert.deepEqual({ expense: summary.totalExpense, income: summary.totalIncome, net: summary.net, transferOut: summary.transferOutflow, interest: summary.interestIncome }, { expense: 6_000, income: 15_000, net: 9_000, transferOut: 3_000, interest: 1_500 }); assert.deepEqual({ expense: summary.totalExpense, income: summary.totalIncome, net: summary.net, transferOut: summary.transferOutflow, interest: summary.interestIncome }, { expense: 6_000, income: 15_000, net: 9_000, transferOut: 3_000, interest: 1_500 });
const categorySummary = await getSummary({ ...params, categoryId: categoryId.expense }, client);
assert.deepEqual({ expense: categorySummary.totalExpense, income: categorySummary.totalIncome }, { expense: 6_000, income: 0 });
assert.equal((await getSummary({ ...params, from: '2026-08-01', to: '2026-08-31' }, client)).totalExpense, 7_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]); assert.deepEqual((await getByCategory(params, client)).map((item) => item.amount), [6_000]);
const timeseries = await getTimeseries({ ...params, granularity: 'month' }, client); const timeseries = await getTimeseries({ ...params, granularity: 'month' }, client);

View File

@@ -14,6 +14,7 @@ interface BaseParams {
from: string; from: string;
to: string; to: string;
accountId?: number; accountId?: number;
categoryId?: number;
onlyConfirmed?: boolean; onlyConfirmed?: boolean;
} }
@@ -61,6 +62,11 @@ function buildBaseConditions(
values.push(params.accountId); values.push(params.accountId);
idx++; idx++;
} }
if (params.categoryId != null) {
conditions.push(`t.category_id = $${idx}`);
values.push(params.categoryId);
idx++;
}
if (params.onlyConfirmed) { if (params.onlyConfirmed) {
conditions.push('t.is_category_confirmed = TRUE'); conditions.push('t.is_category_confirmed = TRUE');
} }

View File

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

View File

@@ -1,12 +1,14 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import type { import type {
Account, Account,
Category,
AnalyticsSummaryResponse, AnalyticsSummaryResponse,
ByCategoryItem, ByCategoryItem,
TimeseriesItem, TimeseriesItem,
Granularity, Granularity,
} from '@family-budget/shared'; } from '@family-budget/shared';
import { getAccounts } from '../api/accounts'; import { getAccounts } from '../api/accounts';
import { getCategories } from '../api/categories';
import { getSummary, getByCategory, getTimeseries } from '../api/analytics'; import { getSummary, getByCategory, getTimeseries } from '../api/analytics';
import { import {
PeriodSelector, PeriodSelector,
@@ -29,8 +31,10 @@ function getDefaultPeriod(): PeriodState {
export function AnalyticsPage() { export function AnalyticsPage() {
const [period, setPeriod] = useState<PeriodState>(getDefaultPeriod); const [period, setPeriod] = useState<PeriodState>(getDefaultPeriod);
const [accountId, setAccountId] = useState<string>(''); const [accountId, setAccountId] = useState<string>('');
const [categoryId, setCategoryId] = useState<string>('');
const [onlyConfirmed, setOnlyConfirmed] = useState(false); const [onlyConfirmed, setOnlyConfirmed] = useState(false);
const [accounts, setAccounts] = useState<Account[]>([]); const [accounts, setAccounts] = useState<Account[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [summary, setSummary] = useState<AnalyticsSummaryResponse | null>( const [summary, setSummary] = useState<AnalyticsSummaryResponse | null>(
null, null,
); );
@@ -40,6 +44,7 @@ export function AnalyticsPage() {
useEffect(() => { useEffect(() => {
getAccounts().then(setAccounts).catch(() => {}); getAccounts().then(setAccounts).catch(() => {});
getCategories({ isActive: true }).then(setCategories).catch(() => {});
}, []); }, []);
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
@@ -63,6 +68,7 @@ export function AnalyticsPage() {
from: period.from, from: period.from,
to: period.to, to: period.to,
...(accountId ? { accountId: Number(accountId) } : {}), ...(accountId ? { accountId: Number(accountId) } : {}),
...(categoryId ? { categoryId: Number(categoryId) } : {}),
...(onlyConfirmed ? { onlyConfirmed: true } : {}), ...(onlyConfirmed ? { onlyConfirmed: true } : {}),
}; };
@@ -80,7 +86,7 @@ export function AnalyticsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [period, accountId, onlyConfirmed]); }, [period, accountId, categoryId, onlyConfirmed]);
useEffect(() => { useEffect(() => {
fetchAll(); fetchAll();
@@ -112,6 +118,13 @@ export function AnalyticsPage() {
))} ))}
</select> </select>
</div> </div>
<div className="field">
<label className="field__label">Категория</label>
<select value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
<option value="">Все категории</option>
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="field field--checkbox"> <div className="field field--checkbox">
<label className="field__label field__label--checkbox"> <label className="field__label field__label--checkbox">
<input <input

View File

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

View File

@@ -4,6 +4,7 @@ export interface AnalyticsSummaryParams {
from: string; from: string;
to: string; to: string;
accountId?: number; accountId?: number;
categoryId?: number;
onlyConfirmed?: boolean; onlyConfirmed?: boolean;
} }
@@ -31,6 +32,7 @@ export interface ByCategoryParams {
from: string; from: string;
to: string; to: string;
accountId?: number; accountId?: number;
categoryId?: number;
onlyConfirmed?: boolean; onlyConfirmed?: boolean;
} }