Compare commits
1 Commits
main
...
feature/po
| Author | SHA1 | Date | |
|---|---|---|---|
| 4911f65b7b |
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
(
|
||||
|
||||
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;
|
||||
@@ -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<PortfolioOverviewResponse>
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.13.0",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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<ImportPortfolioResponse> {
|
||||
@@ -14,3 +14,7 @@ export function importBrokerReport(file: File): Promise<ImportBrokerReportRespon
|
||||
export function getPortfolioOverview(): Promise<PortfolioOverviewResponse> {
|
||||
return api.get('/api/portfolio');
|
||||
}
|
||||
|
||||
export function getPortfolioHistory(): Promise<PortfolioHistoryResponse> {
|
||||
return api.get('/api/portfolio/history');
|
||||
}
|
||||
|
||||
@@ -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<PortfolioOverviewResponse | null>(null);
|
||||
const [history, setHistory] = useState<PortfolioHistoryResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getPortfolioOverview().then(setData).catch(() => {});
|
||||
getPortfolioHistory().then(setHistory).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -50,6 +53,28 @@ export function PortfolioPage() {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -368,6 +368,11 @@ button {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.portfolio__chart {
|
||||
min-height: 280px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Forms, buttons, badges
|
||||
================================================================ */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ export type {
|
||||
PortfolioOverviewPosition,
|
||||
PortfolioOverviewAccount,
|
||||
PortfolioOverviewResponse,
|
||||
PortfolioHistoryPoint,
|
||||
PortfolioHistoryAccount,
|
||||
PortfolioHistoryResponse,
|
||||
} from './import';
|
||||
|
||||
export type {
|
||||
|
||||
Reference in New Issue
Block a user