feat: add portfolio overview
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.12.0",
|
||||
"version": "0.13.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/" element={<Navigate to="/history" replace />} />
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/portfolio" element={<PortfolioPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/history" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -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<ImportPortfolioResponse> {
|
||||
@@ -10,3 +10,7 @@ export function importBrokerReport(file: File): Promise<ImportBrokerReportRespon
|
||||
formData.append('file', file);
|
||||
return api.postFormData('/api/import/broker', formData);
|
||||
}
|
||||
|
||||
export function getPortfolioOverview(): Promise<PortfolioOverviewResponse> {
|
||||
return api.get('/api/portfolio');
|
||||
}
|
||||
|
||||
@@ -63,6 +63,21 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
Операции
|
||||
</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
|
||||
to="/analytics"
|
||||
className={({ isActive }) =>
|
||||
|
||||
55
frontend/src/pages/PortfolioPage.tsx
Normal file
55
frontend/src/pages/PortfolioPage.tsx
Normal file
@@ -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<PortfolioOverviewResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getPortfolioOverview().then(setData).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>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user