From b38346dd19991d3f65628120ab5995990f9d890c Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 27 Aug 2026 00:07:25 +0300 Subject: [PATCH] feat: add portfolio overview --- backend/package.json | 3 +- backend/src/app.ts | 2 + backend/src/routes/portfolioOverview.ts | 14 ++++ backend/src/services/portfolio.ts | 67 ++++++++++++++++++- .../src/services/portfolioOverview.test.ts | 12 ++++ frontend/package.json | 2 +- frontend/src/App.tsx | 2 + frontend/src/api/portfolio.ts | 6 +- frontend/src/components/Layout.tsx | 15 +++++ frontend/src/pages/PortfolioPage.tsx | 55 +++++++++++++++ frontend/src/styles/index.css | 32 +++++++++ shared/package.json | 2 +- shared/src/types/import.ts | 20 ++++++ shared/src/types/index.ts | 3 + 14 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 backend/src/routes/portfolioOverview.ts create mode 100644 backend/src/services/portfolioOverview.test.ts create mode 100644 frontend/src/pages/PortfolioPage.tsx diff --git a/backend/package.json b/backend/package.json index 0db0b18..9835d44 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/backend", - "version": "0.11.0", + "version": "0.12.0", "private": true, "scripts": { "dev": "tsx watch src/app.ts", @@ -11,6 +11,7 @@ "test:analytics": "tsx src/services/analyticsSemantics.test.ts", "test:analytics:query": "tsx src/routes/analytics.test.ts", "test:portfolio": "tsx src/services/portfolio.test.ts", + "test:portfolio:overview": "tsx src/services/portfolioOverview.test.ts", "test:portfolio:db": "NODE_ENV=test tsx src/services/portfolio.integration.test.ts", "test:transactions": "tsx src/services/transactions.test.ts", "test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts", diff --git a/backend/src/app.ts b/backend/src/app.ts index 4e2113f..693cd6b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -20,6 +20,7 @@ import categoriesRouter from './routes/categories'; import categoryRulesRouter from './routes/categoryRules'; import analyticsRouter from './routes/analytics'; import portfolioRouter from './routes/portfolio'; +import portfolioOverviewRouter from './routes/portfolioOverview'; const app = express(); app.set('trust proxy', 1); @@ -45,6 +46,7 @@ app.use('/api/categories', categoriesRouter); app.use('/api/category-rules', categoryRulesRouter); app.use('/api/analytics', analyticsRouter); app.use('/api/import/portfolio', portfolioRouter); +app.use('/api/portfolio', portfolioOverviewRouter); app.use( ( diff --git a/backend/src/routes/portfolioOverview.ts b/backend/src/routes/portfolioOverview.ts new file mode 100644 index 0000000..a8d9059 --- /dev/null +++ b/backend/src/routes/portfolioOverview.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { asyncHandler } from '../utils'; +import { getPortfolioOverview } from '../services/portfolio'; + +const router = Router(); + +router.get( + '/', + asyncHandler(async (_req, res) => { + res.json(await getPortfolioOverview()); + }), +); + +export default router; diff --git a/backend/src/services/portfolio.ts b/backend/src/services/portfolio.ts index 32591f8..a3d6ce8 100644 --- a/backend/src/services/portfolio.ts +++ b/backend/src/services/portfolio.ts @@ -1,10 +1,25 @@ import crypto from 'crypto'; import type { PoolClient } from 'pg'; import { pool } from '../db/pool'; -import type { ImportPortfolioResponse, PortfolioFile, PortfolioTrade } from '@family-budget/shared'; +import { maskAccountNumber } from '../utils'; +import type { ImportPortfolioResponse, PortfolioFile, 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; +type PortfolioOverviewRow = { + account_id: number | string; + alias: string | null; + bank: string; + account_number: string; + report_period_to: string; + total_valuation: string | number | null; + instrument: string | null; + isin: string | null; + quantity: string | number | null; + price: string | number | null; + 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`); @@ -103,3 +118,53 @@ export async function importPortfolio(body: unknown, db: Pick(); + 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)}`, + reportPeriodTo: row.report_period_to, + totalValuation: row.total_valuation === null ? null : String(row.total_valuation), + positions: [], + }; + accounts.set(accountId, account); + } + if (row.instrument !== null) { + const valuation = row.valuation === null ? null : String(row.valuation); + account.positions.push({ + instrument: row.instrument, + isin: row.isin, + quantity: String(row.quantity), + price: row.price === null ? null : String(row.price), + valuation, + }); + } + } + return { accounts: [...accounts.values()] }; +} + +export async function getPortfolioOverview(): 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) OVER (PARTITION BY a.id) AS total_valuation, + p.instrument, p.isin, p.quantity, p.price, p.valuation + FROM accounts a + JOIN LATERAL ( + SELECT id, report_period_to, reported_at, imported_at + FROM portfolio_reports + WHERE account_id = a.id + ORDER BY report_period_to DESC, imported_at DESC, id DESC + LIMIT 1 + ) r ON TRUE + LEFT JOIN portfolio_positions p ON p.report_id = r.id + WHERE a.account_type IN ('brokerage', 'iis') + ORDER BY a.id, p.valuation DESC NULLS LAST, p.id`, + ); + return toPortfolioOverview(rows); +} diff --git a/backend/src/services/portfolioOverview.test.ts b/backend/src/services/portfolioOverview.test.ts new file mode 100644 index 0000000..57b40e1 --- /dev/null +++ b/backend/src/services/portfolioOverview.test.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import { 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' }, + { account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-26', total_valuation: '150.50', instrument: 'Фонд', isin: null, quantity: '2', price: '25.25', valuation: '50.50' }, +]); + +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'); diff --git a/frontend/package.json b/frontend/package.json index 468d649..386f727 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/frontend", - "version": "0.12.0", + "version": "0.13.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1280539..7e78724 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { LoginPage } from './pages/LoginPage'; import { HistoryPage } from './pages/HistoryPage'; import { AnalyticsPage } from './pages/AnalyticsPage'; import { SettingsPage } from './pages/SettingsPage'; +import { PortfolioPage } from './pages/PortfolioPage'; export function App() { const { user, loading } = useAuth(); @@ -23,6 +24,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/portfolio.ts b/frontend/src/api/portfolio.ts index c405287..be4a956 100644 --- a/frontend/src/api/portfolio.ts +++ b/frontend/src/api/portfolio.ts @@ -1,4 +1,4 @@ -import type { ImportBrokerReportResponse, ImportPortfolioResponse, PortfolioFile } from '@family-budget/shared'; +import type { ImportBrokerReportResponse, ImportPortfolioResponse, PortfolioFile, PortfolioOverviewResponse } from '@family-budget/shared'; import { api } from './client'; export function importPortfolio(data: PortfolioFile): Promise { @@ -10,3 +10,7 @@ export function importBrokerReport(file: File): Promise { + return api.get('/api/portfolio'); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 8cd5d6c..548c49f 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -63,6 +63,21 @@ export function Layout({ children }: { children: ReactNode }) { Операции + + `sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}` + } + onClick={closeDrawer} + > + + + + + + Портфель + + diff --git a/frontend/src/pages/PortfolioPage.tsx b/frontend/src/pages/PortfolioPage.tsx new file mode 100644 index 0000000..da61f9b --- /dev/null +++ b/frontend/src/pages/PortfolioPage.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import type { PortfolioOverviewResponse } from '@family-budget/shared'; +import { getPortfolioOverview } from '../api/portfolio'; +import { formatDate } from '../utils/format'; + +const money = new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', minimumFractionDigits: 2 }); +const number = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 6 }); + +export function PortfolioPage() { + const [data, setData] = useState(null); + + useEffect(() => { + getPortfolioOverview().then(setData).catch(() => {}); + }, []); + + return ( +
+
+
+

Инвестиции

+

Портфель

+
+
+ + {!data ?
Загрузка...
: data.accounts.length === 0 ? ( +
Портфель пока пуст. Загрузите XLSX-отчёт брокера в разделе «Операции».
+ ) : data.accounts.map((account) => ( +
+
+
+

{account.accountName}

+

Состояние на {formatDate(account.reportPeriodTo)}

+
+ {account.totalValuation !== null && {money.format(Number(account.totalValuation))}} +
+ {account.positions.length === 0 ?
В последнем отчёте нет открытых позиций.
: ( +
+ + + + {account.positions.map((position, index) => + + + + + )} + +
ИнструментКоличествоЦенаСтоимость
{position.instrument}
{position.isin &&
{position.isin}
}
{number.format(Number(position.quantity))}{position.price === null ? '—' : money.format(Number(position.price))}{position.valuation === null ? '—' : money.format(Number(position.valuation))}
+
+ )} +
+ ))} +
+ ); +} diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index d6b25de..eb2040b 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -341,6 +341,33 @@ button { color: var(--color-text-muted); } +.portfolio { + margin-bottom: 20px; +} + +.portfolio__header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.portfolio__title { + font-size: 18px; + font-weight: 850; +} + +.portfolio__meta { + margin-top: 3px; + color: var(--color-text-secondary); +} + +.portfolio__total { + font-size: 20px; + font-variant-numeric: tabular-nums; +} + /* ================================================================ Forms, buttons, badges ================================================================ */ @@ -1462,6 +1489,11 @@ input[type="checkbox"] { width: 100%; } + .portfolio__header { + align-items: flex-start; + flex-direction: column; + } + .filters__row, .analytics-panel__filters { align-items: stretch; diff --git a/shared/package.json b/shared/package.json index c2aa476..179a01a 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/shared", - "version": "0.6.0", + "version": "0.7.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 14a7622..b4a277b 100644 --- a/shared/src/types/import.ts +++ b/shared/src/types/import.ts @@ -98,3 +98,23 @@ export interface ImportBrokerReportResponse { cash: ImportStatementResponse; portfolio: ImportPortfolioResponse; } + +export interface PortfolioOverviewPosition { + instrument: string; + isin: string | null; + quantity: string; + price: string | null; + valuation: string | null; +} + +export interface PortfolioOverviewAccount { + accountId: number; + accountName: string; + reportPeriodTo: string; + totalValuation: string | null; + positions: PortfolioOverviewPosition[]; +} + +export interface PortfolioOverviewResponse { + accounts: PortfolioOverviewAccount[]; +} diff --git a/shared/src/types/index.ts b/shared/src/types/index.ts index 81023df..57588e8 100644 --- a/shared/src/types/index.ts +++ b/shared/src/types/index.ts @@ -44,6 +44,9 @@ export type { PortfolioTrade, ImportPortfolioResponse, ImportBrokerReportResponse, + PortfolioOverviewPosition, + PortfolioOverviewAccount, + PortfolioOverviewResponse, } from './import'; export type {