diff --git a/backend/package.json b/backend/package.json index 9835d44..bab1264 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/backend", - "version": "0.12.0", + "version": "0.13.0", "private": true, "scripts": { "dev": "tsx watch src/app.ts", diff --git a/backend/src/app.ts b/backend/src/app.ts index 693cd6b..f62874b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -21,6 +21,7 @@ import categoryRulesRouter from './routes/categoryRules'; import analyticsRouter from './routes/analytics'; import portfolioRouter from './routes/portfolio'; import portfolioOverviewRouter from './routes/portfolioOverview'; +import portfolioHistoryRouter from './routes/portfolioHistory'; const app = express(); app.set('trust proxy', 1); @@ -47,6 +48,7 @@ app.use('/api/category-rules', categoryRulesRouter); app.use('/api/analytics', analyticsRouter); app.use('/api/import/portfolio', portfolioRouter); app.use('/api/portfolio', portfolioOverviewRouter); +app.use('/api/portfolio/history', portfolioHistoryRouter); app.use( ( diff --git a/backend/src/routes/portfolioHistory.ts b/backend/src/routes/portfolioHistory.ts new file mode 100644 index 0000000..a50082d --- /dev/null +++ b/backend/src/routes/portfolioHistory.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { asyncHandler } from '../utils'; +import { getPortfolioHistory } from '../services/portfolio'; + +const router = Router(); + +router.get( + '/', + asyncHandler(async (_req, res) => { + res.json(await getPortfolioHistory()); + }), +); + +export default router; diff --git a/backend/src/services/portfolio.ts b/backend/src/services/portfolio.ts index a3d6ce8..1244ebc 100644 --- a/backend/src/services/portfolio.ts +++ b/backend/src/services/portfolio.ts @@ -2,7 +2,7 @@ import crypto from 'crypto'; import type { PoolClient } from 'pg'; import { pool } from '../db/pool'; import { maskAccountNumber } from '../utils'; -import type { ImportPortfolioResponse, PortfolioFile, PortfolioOverviewResponse, PortfolioTrade } from '@family-budget/shared'; +import type { ImportPortfolioResponse, PortfolioFile, PortfolioHistoryResponse, PortfolioOverviewResponse, PortfolioTrade } from '@family-budget/shared'; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -20,6 +20,15 @@ type PortfolioOverviewRow = { valuation: string | number | null; }; +type PortfolioHistoryRow = { + account_id: number | string; + alias: string | null; + bank: string; + account_number: string; + report_period_to: string; + total_valuation: string | number | null; +}; + function decimalValue(value: unknown, field: string, required = false): string | null { if (value == null || value === '') { if (required) throw new Error(`${field} is required`); @@ -168,3 +177,38 @@ export async function getPortfolioOverview(): Promise ); return toPortfolioOverview(rows); } + +export function toPortfolioHistory(rows: PortfolioHistoryRow[]): PortfolioHistoryResponse { + const accounts = new Map(); + for (const row of rows) { + const accountId = Number(row.account_id); + let account = accounts.get(accountId); + if (!account) { + account = { + accountId, + accountName: row.alias || `${row.bank} · ${maskAccountNumber(row.account_number)}`, + points: [], + }; + accounts.set(accountId, account); + } + account.points.push({ + reportPeriodTo: row.report_period_to, + totalValuation: row.total_valuation === null ? null : String(row.total_valuation), + }); + } + return { accounts: [...accounts.values()] }; +} + +export async function getPortfolioHistory(): Promise { + const { rows } = await pool.query( + `SELECT a.id AS account_id, a.alias, a.bank, a.account_number, + r.report_period_to, SUM(p.valuation) AS total_valuation + FROM accounts a + JOIN portfolio_reports r ON r.account_id = a.id + LEFT JOIN portfolio_positions p ON p.report_id = r.id + WHERE a.account_type IN ('brokerage', 'iis') + GROUP BY a.id, a.alias, a.bank, a.account_number, r.id, r.report_period_to, r.reported_at, r.imported_at + ORDER BY a.id, r.report_period_to, r.reported_at, r.imported_at, r.id`, + ); + return toPortfolioHistory(rows); +} diff --git a/backend/src/services/portfolioOverview.test.ts b/backend/src/services/portfolioOverview.test.ts index 57b40e1..7b4ea78 100644 --- a/backend/src/services/portfolioOverview.test.ts +++ b/backend/src/services/portfolioOverview.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { toPortfolioOverview } from './portfolio'; +import { toPortfolioHistory, toPortfolioOverview } from './portfolio'; const result = toPortfolioOverview([ { account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-26', total_valuation: '150.50', instrument: 'Облигация', isin: 'RU0000000001', quantity: '1', price: '100', valuation: '100' }, @@ -10,3 +10,10 @@ assert.equal(result.accounts[0].accountName, 'ИИС'); assert.equal(result.accounts[0].totalValuation, '150.50'); assert.equal(result.accounts[0].positions.length, 2); console.log('portfolio overview: OK'); + +const history = toPortfolioHistory([ + { account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-20', total_valuation: '100' }, + { account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-26', total_valuation: '150.50' }, +]); +assert.deepEqual(history.accounts[0].points.map((point) => point.totalValuation), ['100', '150.50']); +console.log('portfolio history: OK'); diff --git a/frontend/package.json b/frontend/package.json index 386f727..cd36830 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/frontend", - "version": "0.13.0", + "version": "0.14.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/portfolio.ts b/frontend/src/api/portfolio.ts index be4a956..a30583e 100644 --- a/frontend/src/api/portfolio.ts +++ b/frontend/src/api/portfolio.ts @@ -1,4 +1,4 @@ -import type { ImportBrokerReportResponse, ImportPortfolioResponse, PortfolioFile, PortfolioOverviewResponse } from '@family-budget/shared'; +import type { ImportBrokerReportResponse, ImportPortfolioResponse, PortfolioFile, PortfolioHistoryResponse, PortfolioOverviewResponse } from '@family-budget/shared'; import { api } from './client'; export function importPortfolio(data: PortfolioFile): Promise { @@ -14,3 +14,7 @@ export function importBrokerReport(file: File): Promise { return api.get('/api/portfolio'); } + +export function getPortfolioHistory(): Promise { + return api.get('/api/portfolio/history'); +} diff --git a/frontend/src/pages/PortfolioPage.tsx b/frontend/src/pages/PortfolioPage.tsx index da61f9b..27c6ede 100644 --- a/frontend/src/pages/PortfolioPage.tsx +++ b/frontend/src/pages/PortfolioPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; -import type { PortfolioOverviewResponse } from '@family-budget/shared'; -import { getPortfolioOverview } from '../api/portfolio'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import type { PortfolioHistoryResponse, PortfolioOverviewResponse } from '@family-budget/shared'; +import { getPortfolioHistory, getPortfolioOverview } from '../api/portfolio'; import { formatDate } from '../utils/format'; const money = new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', minimumFractionDigits: 2 }); @@ -8,9 +9,11 @@ const number = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 6 }); export function PortfolioPage() { const [data, setData] = useState(null); + const [history, setHistory] = useState(null); useEffect(() => { getPortfolioOverview().then(setData).catch(() => {}); + getPortfolioHistory().then(setHistory).catch(() => {}); }, []); return ( @@ -50,6 +53,28 @@ export function PortfolioPage() { )} ))} + + {history?.accounts.map((account) => account.points.length > 1 && ( +
+
+
+

Динамика · {account.accountName}

+

Стоимость по загруженным отчётам

+
+
+
+ + ({ date: point.reportPeriodTo, value: point.totalValuation === null ? null : Number(point.totalValuation) }))}> + + formatDate(value)} fontSize={12} stroke="var(--color-text-secondary)" tickLine={false} axisLine={false} /> + `${Math.round(value / 1000)}к`} fontSize={12} stroke="var(--color-text-secondary)" tickLine={false} axisLine={false} /> + formatDate(String(value))} formatter={(value) => value == null ? '—' : money.format(Number(value))} /> + + + +
+
+ ))} ); } diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index eb2040b..1aefe65 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -368,6 +368,11 @@ button { font-variant-numeric: tabular-nums; } +.portfolio__chart { + min-height: 280px; + padding: 16px; +} + /* ================================================================ Forms, buttons, badges ================================================================ */ diff --git a/shared/package.json b/shared/package.json index 179a01a..f719d61 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/shared", - "version": "0.7.0", + "version": "0.8.0", "private": true, "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/shared/src/types/import.ts b/shared/src/types/import.ts index b4a277b..123a14a 100644 --- a/shared/src/types/import.ts +++ b/shared/src/types/import.ts @@ -118,3 +118,18 @@ export interface PortfolioOverviewAccount { export interface PortfolioOverviewResponse { accounts: PortfolioOverviewAccount[]; } + +export interface PortfolioHistoryPoint { + reportPeriodTo: string; + totalValuation: string | null; +} + +export interface PortfolioHistoryAccount { + accountId: number; + accountName: string; + points: PortfolioHistoryPoint[]; +} + +export interface PortfolioHistoryResponse { + accounts: PortfolioHistoryAccount[]; +} diff --git a/shared/src/types/index.ts b/shared/src/types/index.ts index 57588e8..aea7908 100644 --- a/shared/src/types/index.ts +++ b/shared/src/types/index.ts @@ -47,6 +47,9 @@ export type { PortfolioOverviewPosition, PortfolioOverviewAccount, PortfolioOverviewResponse, + PortfolioHistoryPoint, + PortfolioHistoryAccount, + PortfolioHistoryResponse, } from './import'; export type {