Compare commits
4 Commits
feature/br
...
feature/po
| Author | SHA1 | Date | |
|---|---|---|---|
| 4911f65b7b | |||
| e7233d85ee | |||
| b38346dd19 | |||
| 1e25a0152a |
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [Frontend 0.13.0 / Backend 0.12.0 / Shared 0.7.0] - 2026-08-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a separate portfolio view with the latest broker and IIS positions and total valuation.
|
||||||
|
|
||||||
## [Frontend 0.12.0 / Backend 0.11.0 / Shared 0.6.0] - 2026-08-26
|
## [Frontend 0.12.0 / Backend 0.11.0 / Shared 0.6.0] - 2026-08-26
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/backend",
|
"name": "@family-budget/backend",
|
||||||
"version": "0.11.0",
|
"version": "0.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/app.ts",
|
"dev": "tsx watch src/app.ts",
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"test:analytics": "tsx src/services/analyticsSemantics.test.ts",
|
"test:analytics": "tsx src/services/analyticsSemantics.test.ts",
|
||||||
"test:analytics:query": "tsx src/routes/analytics.test.ts",
|
"test:analytics:query": "tsx src/routes/analytics.test.ts",
|
||||||
"test:portfolio": "tsx src/services/portfolio.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:portfolio:db": "NODE_ENV=test tsx src/services/portfolio.integration.test.ts",
|
||||||
"test:transactions": "tsx src/services/transactions.test.ts",
|
"test:transactions": "tsx src/services/transactions.test.ts",
|
||||||
"test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts",
|
"test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts",
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import categoriesRouter from './routes/categories';
|
|||||||
import categoryRulesRouter from './routes/categoryRules';
|
import categoryRulesRouter from './routes/categoryRules';
|
||||||
import analyticsRouter from './routes/analytics';
|
import analyticsRouter from './routes/analytics';
|
||||||
import portfolioRouter from './routes/portfolio';
|
import portfolioRouter from './routes/portfolio';
|
||||||
|
import portfolioOverviewRouter from './routes/portfolioOverview';
|
||||||
|
import portfolioHistoryRouter from './routes/portfolioHistory';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.set('trust proxy', 1);
|
app.set('trust proxy', 1);
|
||||||
@@ -45,6 +47,8 @@ app.use('/api/categories', categoriesRouter);
|
|||||||
app.use('/api/category-rules', categoryRulesRouter);
|
app.use('/api/category-rules', categoryRulesRouter);
|
||||||
app.use('/api/analytics', analyticsRouter);
|
app.use('/api/analytics', analyticsRouter);
|
||||||
app.use('/api/import/portfolio', portfolioRouter);
|
app.use('/api/import/portfolio', portfolioRouter);
|
||||||
|
app.use('/api/portfolio', portfolioOverviewRouter);
|
||||||
|
app.use('/api/portfolio/history', portfolioHistoryRouter);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
(
|
(
|
||||||
|
|||||||
14
backend/src/routes/portfolioHistory.ts
Normal file
14
backend/src/routes/portfolioHistory.ts
Normal file
@@ -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;
|
||||||
14
backend/src/routes/portfolioOverview.ts
Normal file
14
backend/src/routes/portfolioOverview.ts
Normal file
@@ -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;
|
||||||
@@ -1,10 +1,34 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import type { PoolClient } from 'pg';
|
import type { PoolClient } from 'pg';
|
||||||
import { pool } from '../db/pool';
|
import { pool } from '../db/pool';
|
||||||
import type { ImportPortfolioResponse, PortfolioFile, PortfolioTrade } from '@family-budget/shared';
|
import { maskAccountNumber } from '../utils';
|
||||||
|
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;
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 {
|
function decimalValue(value: unknown, field: string, required = false): string | null {
|
||||||
if (value == null || value === '') {
|
if (value == null || value === '') {
|
||||||
if (required) throw new Error(`${field} is required`);
|
if (required) throw new Error(`${field} is required`);
|
||||||
@@ -103,3 +127,88 @@ export async function importPortfolio(body: unknown, db: Pick<typeof pool, 'conn
|
|||||||
if (ownsTransaction) client.release();
|
if (ownsTransaction) client.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toPortfolioOverview(rows: PortfolioOverviewRow[]): PortfolioOverviewResponse {
|
||||||
|
const accounts = new Map<number, PortfolioOverviewResponse['accounts'][number]>();
|
||||||
|
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<PortfolioOverviewResponse> {
|
||||||
|
const { rows } = await pool.query<PortfolioOverviewRow>(
|
||||||
|
`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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPortfolioHistory(rows: PortfolioHistoryRow[]): PortfolioHistoryResponse {
|
||||||
|
const accounts = new Map<number, PortfolioHistoryResponse['accounts'][number]>();
|
||||||
|
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<PortfolioHistoryResponse> {
|
||||||
|
const { rows } = await pool.query<PortfolioHistoryRow>(
|
||||||
|
`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);
|
||||||
|
}
|
||||||
|
|||||||
19
backend/src/services/portfolioOverview.test.ts
Normal file
19
backend/src/services/portfolioOverview.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
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' },
|
||||||
|
{ 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');
|
||||||
|
|
||||||
|
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');
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/frontend",
|
"name": "@family-budget/frontend",
|
||||||
"version": "0.12.0",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { LoginPage } from './pages/LoginPage';
|
|||||||
import { HistoryPage } from './pages/HistoryPage';
|
import { HistoryPage } from './pages/HistoryPage';
|
||||||
import { AnalyticsPage } from './pages/AnalyticsPage';
|
import { AnalyticsPage } from './pages/AnalyticsPage';
|
||||||
import { SettingsPage } from './pages/SettingsPage';
|
import { SettingsPage } from './pages/SettingsPage';
|
||||||
|
import { PortfolioPage } from './pages/PortfolioPage';
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -23,6 +24,7 @@ export function App() {
|
|||||||
<Route path="/" element={<Navigate to="/history" replace />} />
|
<Route path="/" element={<Navigate to="/history" replace />} />
|
||||||
<Route path="/history" element={<HistoryPage />} />
|
<Route path="/history" element={<HistoryPage />} />
|
||||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
|
<Route path="/portfolio" element={<PortfolioPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="*" element={<Navigate to="/history" replace />} />
|
<Route path="*" element={<Navigate to="/history" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ImportBrokerReportResponse, ImportPortfolioResponse, PortfolioFile } from '@family-budget/shared';
|
import type { ImportBrokerReportResponse, ImportPortfolioResponse, PortfolioFile, PortfolioHistoryResponse, PortfolioOverviewResponse } from '@family-budget/shared';
|
||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
|
|
||||||
export function importPortfolio(data: PortfolioFile): Promise<ImportPortfolioResponse> {
|
export function importPortfolio(data: PortfolioFile): Promise<ImportPortfolioResponse> {
|
||||||
@@ -10,3 +10,11 @@ export function importBrokerReport(file: File): Promise<ImportBrokerReportRespon
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
return api.postFormData('/api/import/broker', formData);
|
return api.postFormData('/api/import/broker', formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPortfolioOverview(): Promise<PortfolioOverviewResponse> {
|
||||||
|
return api.get('/api/portfolio');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPortfolioHistory(): Promise<PortfolioHistoryResponse> {
|
||||||
|
return api.get('/api/portfolio/history');
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,21 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
Операции
|
Операции
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
to="/portfolio"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}`
|
||||||
|
}
|
||||||
|
onClick={closeDrawer}
|
||||||
|
>
|
||||||
|
<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="M3 21h18" />
|
||||||
|
<path d="M5 21V10l7-5 7 5v11" />
|
||||||
|
<path d="M9 21v-6h6v6" />
|
||||||
|
</svg>
|
||||||
|
Портфель
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/analytics"
|
to="/analytics"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
|
|||||||
80
frontend/src/pages/PortfolioPage.tsx
Normal file
80
frontend/src/pages/PortfolioPage.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
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 });
|
||||||
|
const number = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 6 });
|
||||||
|
|
||||||
|
export function PortfolioPage() {
|
||||||
|
const [data, setData] = useState<PortfolioOverviewResponse | null>(null);
|
||||||
|
const [history, setHistory] = useState<PortfolioHistoryResponse | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPortfolioOverview().then(setData).catch(() => {});
|
||||||
|
getPortfolioHistory().then(setHistory).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page__header">
|
||||||
|
<div>
|
||||||
|
<p className="page__eyebrow">Инвестиции</p>
|
||||||
|
<h1 className="page__title">Портфель</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!data ? <div className="state">Загрузка...</div> : data.accounts.length === 0 ? (
|
||||||
|
<div className="state state--empty">Портфель пока пуст. Загрузите XLSX-отчёт брокера в разделе «Операции».</div>
|
||||||
|
) : data.accounts.map((account) => (
|
||||||
|
<section className="portfolio" key={account.accountId} aria-labelledby={`portfolio-${account.accountId}`}>
|
||||||
|
<div className="portfolio__header">
|
||||||
|
<div>
|
||||||
|
<h2 id={`portfolio-${account.accountId}`} className="portfolio__title">{account.accountName}</h2>
|
||||||
|
<p className="portfolio__meta">Состояние на {formatDate(account.reportPeriodTo)}</p>
|
||||||
|
</div>
|
||||||
|
{account.totalValuation !== null && <strong className="portfolio__total">{money.format(Number(account.totalValuation))}</strong>}
|
||||||
|
</div>
|
||||||
|
{account.positions.length === 0 ? <div className="state state--empty">В последнем отчёте нет открытых позиций.</div> : (
|
||||||
|
<div className="table-shell">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead><tr><th className="data-table__head-cell" scope="col">Инструмент</th><th className="data-table__head-cell" scope="col">Количество</th><th className="data-table__head-cell" scope="col">Цена</th><th className="data-table__head-cell" scope="col">Стоимость</th></tr></thead>
|
||||||
|
<tbody className="data-table__body">
|
||||||
|
{account.positions.map((position, index) => <tr className="data-table__row" key={`${position.isin ?? position.instrument}-${index}`}>
|
||||||
|
<td className="data-table__cell"><div className="data-table__description">{position.instrument}</div>{position.isin && <div className="data-table__subtext">{position.isin}</div>}</td>
|
||||||
|
<td className="data-table__cell data-table__cell--nowrap">{number.format(Number(position.quantity))}</td>
|
||||||
|
<td className="data-table__cell data-table__cell--nowrap">{position.price === null ? '—' : money.format(Number(position.price))}</td>
|
||||||
|
<td className="data-table__cell data-table__cell--nowrap money-amount">{position.valuation === null ? '—' : money.format(Number(position.valuation))}</td>
|
||||||
|
</tr>)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{history?.accounts.map((account) => account.points.length > 1 && (
|
||||||
|
<section className="portfolio" key={`history-${account.accountId}`} aria-labelledby={`history-${account.accountId}`}>
|
||||||
|
<div className="portfolio__header">
|
||||||
|
<div>
|
||||||
|
<h2 id={`history-${account.accountId}`} className="portfolio__title">Динамика · {account.accountName}</h2>
|
||||||
|
<p className="portfolio__meta">Стоимость по загруженным отчётам</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chart-card portfolio__chart">
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<LineChart data={account.points.map((point) => ({ date: point.reportPeriodTo, value: point.totalValuation === null ? null : Number(point.totalValuation) }))}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" vertical={false} />
|
||||||
|
<XAxis dataKey="date" tickFormatter={(value: string) => formatDate(value)} fontSize={12} stroke="var(--color-text-secondary)" tickLine={false} axisLine={false} />
|
||||||
|
<YAxis tickFormatter={(value: number) => `${Math.round(value / 1000)}к`} fontSize={12} stroke="var(--color-text-secondary)" tickLine={false} axisLine={false} />
|
||||||
|
<Tooltip labelFormatter={(value) => formatDate(String(value))} formatter={(value) => value == null ? '—' : money.format(Number(value))} />
|
||||||
|
<Line type="monotone" dataKey="value" name="Стоимость" stroke="var(--color-primary)" strokeWidth={2} dot={{ r: 3 }} connectNulls />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -341,6 +341,38 @@ button {
|
|||||||
color: var(--color-text-muted);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio__chart {
|
||||||
|
min-height: 280px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
Forms, buttons, badges
|
Forms, buttons, badges
|
||||||
================================================================ */
|
================================================================ */
|
||||||
@@ -1462,6 +1494,11 @@ input[type="checkbox"] {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portfolio__header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.filters__row,
|
.filters__row,
|
||||||
.analytics-panel__filters {
|
.analytics-panel__filters {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/shared",
|
"name": "@family-budget/shared",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -98,3 +98,38 @@ export interface ImportBrokerReportResponse {
|
|||||||
cash: ImportStatementResponse;
|
cash: ImportStatementResponse;
|
||||||
portfolio: ImportPortfolioResponse;
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortfolioHistoryPoint {
|
||||||
|
reportPeriodTo: string;
|
||||||
|
totalValuation: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortfolioHistoryAccount {
|
||||||
|
accountId: number;
|
||||||
|
accountName: string;
|
||||||
|
points: PortfolioHistoryPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortfolioHistoryResponse {
|
||||||
|
accounts: PortfolioHistoryAccount[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ export type {
|
|||||||
PortfolioTrade,
|
PortfolioTrade,
|
||||||
ImportPortfolioResponse,
|
ImportPortfolioResponse,
|
||||||
ImportBrokerReportResponse,
|
ImportBrokerReportResponse,
|
||||||
|
PortfolioOverviewPosition,
|
||||||
|
PortfolioOverviewAccount,
|
||||||
|
PortfolioOverviewResponse,
|
||||||
|
PortfolioHistoryPoint,
|
||||||
|
PortfolioHistoryAccount,
|
||||||
|
PortfolioHistoryResponse,
|
||||||
} from './import';
|
} from './import';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
Reference in New Issue
Block a user