feat: add broker account overview

This commit is contained in:
Sergey Krylov 2026-06-19 06:43:38 +03:00
parent 1c26d2a3eb
commit a19ad67a89
6 changed files with 726 additions and 67 deletions

View File

@ -121,14 +121,18 @@ export function BrokerAccountDetailPage() {
<BrokerPositionsSection accountId={accountId!} /> <BrokerPositionsSection accountId={accountId!} />
<BrokerOperationsTable <BrokerOperationsTable
title="Операции"
emptyMessage="Операций за выбранный период нет"
isLoading={operations.isLoading} isLoading={operations.isLoading}
isFetching={operations.isFetching} isFetching={operations.isFetching}
page={operations.data} page={operations.data}
pageNumber={operationCursorStack.length + 1} pagination={{
canGoBack={operationCursorStack.length > 0} pageNumber: operationCursorStack.length + 1,
canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)} canGoBack: operationCursorStack.length > 0,
onPrevious={handlePreviousOperationsPage} canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor),
onNext={handleNextOperationsPage} onPrevious: handlePreviousOperationsPage,
onNext: handleNextOperationsPage,
}}
/> />
</div> </div>
); );

View File

@ -0,0 +1,172 @@
import { Link } from 'react-router-dom';
import type { BrokerMoney, BrokerPortfolio } from '../../api/responses';
import { SkeletonBlock } from '../../components/SkeletonBlock';
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
import { useBrokerAccountContext } from './BrokerAccountLayout';
import { BrokerAllocationChart } from './BrokerAllocationChart';
import { BrokerOperationsTable } from './BrokerOperationsTable';
function formatMoney(value: BrokerMoney | null | undefined) {
if (!value) return '-';
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: value.currency || 'RUB',
maximumFractionDigits: 2,
}).format(value.value);
}
function formatPercent(value: number | null) {
if (value === null) return '-';
return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`;
}
function pluralize(count: number, one: string, few: string, many: string) {
const modulo100 = Math.abs(count) % 100;
const modulo10 = modulo100 % 10;
if (modulo100 > 10 && modulo100 < 20) return many;
if (modulo10 === 1) return one;
if (modulo10 >= 2 && modulo10 <= 4) return few;
return many;
}
function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) {
return (
<section className="broker-overview__summary" aria-label="Сводка счёта">
<div className="broker-overview__card">
<span className="broker-overview__label">Стоимость портфеля</span>
<strong className="broker-overview__total">
{formatMoney(portfolio.totals.portfolio)}
</strong>
<span>За день: {formatMoney(portfolio.yields.daily)}</span>
<span>Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)}</span>
<span>Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)}</span>
</div>
<div className="broker-overview__card">
<span className="broker-overview__label">Денежный остаток</span>
{portfolio.cash.length === 0 ? (
<span>Нет денежных остатков</span>
) : (
<ul className="broker-overview__cash">
{portfolio.cash.map((money) => (
<li key={money.currency}>
<span>{money.currency}</span>
<strong>{formatMoney(money)}</strong>
</li>
))}
</ul>
)}
</div>
</section>
);
}
function allocationPercent(value: BrokerMoney | null, total: BrokerMoney | null) {
if (!value || !total || total.value <= 0) return 0;
return (value.value / total.value) * 100;
}
function BrokerAssetCards({
accountId,
portfolio,
}: {
accountId: string;
portfolio: BrokerPortfolio;
}) {
const basePath = `/broker/${encodeURIComponent(accountId)}`;
const cards = [
{
label: 'Акции',
count: portfolio.positionCounts.shares,
countLabel: pluralize(portfolio.positionCounts.shares, 'позиция', 'позиции', 'позиций'),
value: portfolio.totals.shares,
path: `${basePath}/shares`,
},
{
label: 'Облигации',
count: portfolio.positionCounts.bonds,
countLabel: pluralize(portfolio.positionCounts.bonds, 'выпуск', 'выпуска', 'выпусков'),
value: portfolio.totals.bonds,
path: `${basePath}/bonds`,
},
];
return (
<section className="broker-overview__assets" aria-label="Основные классы активов">
{cards.map((card) => (
<Link
className="broker-overview__card broker-overview__asset-link"
key={card.label}
to={card.path}
>
<strong className="broker-overview__asset-title">{card.label}</strong>
<span>
{card.count} {card.countLabel}
</span>
<span>{formatMoney(card.value)}</span>
<span>{allocationPercent(card.value, portfolio.totals.portfolio).toFixed(1)}%</span>
</Link>
))}
</section>
);
}
function BrokerOverviewSkeleton() {
return (
<div className="broker-overview" aria-label="Загрузка сводки счёта">
<div className="broker-overview__summary">
{[1, 2].map((item) => (
<div className="broker-overview__card" key={item}>
<SkeletonBlock height={16} width="45%" />
<SkeletonBlock height={28} width="70%" />
<SkeletonBlock height={16} width="55%" />
</div>
))}
</div>
<div className="broker-allocation">
<SkeletonBlock height={160} width={160} borderRadius={80} />
<SkeletonBlock height={80} width="60%" />
</div>
<div className="broker-overview__assets">
{[1, 2].map((item) => (
<div className="broker-overview__card" key={item}>
<SkeletonBlock height={20} width="35%" />
<SkeletonBlock height={16} width="55%" />
<SkeletonBlock height={16} width="70%" />
</div>
))}
</div>
</div>
);
}
export function BrokerAccountOverviewPage() {
const { accountId, portfolio } = useBrokerAccountContext();
const operations = useBrokerOperations(accountId, { limit: 5 });
if (portfolio.isLoading) return <BrokerOverviewSkeleton />;
if (portfolio.error || !portfolio.data) {
return <p role="alert">Не удалось загрузить сводку счёта</p>;
}
return (
<div className="broker-overview">
<BrokerSummary portfolio={portfolio.data} />
<BrokerAllocationChart portfolio={portfolio.data} />
<BrokerAssetCards accountId={accountId} portfolio={portfolio.data} />
{operations.error ? (
<p role="alert">Не удалось загрузить последние операции</p>
) : (
<BrokerOperationsTable
title="Последние операции"
headerAction={
<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Вся история</Link>
}
emptyMessage="Операций с начала текущего года нет"
isLoading={operations.isLoading}
isFetching={operations.isFetching}
page={operations.data}
/>
)}
</div>
);
}

View File

@ -0,0 +1,78 @@
import type { BrokerPortfolio } from '../../api/responses';
import { buildBrokerAllocation } from './brokerAllocation';
const RADIUS = 44;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
function formatMoneyValue(value: number) {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 2,
}).format(value);
}
export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfolio }) {
const { sectors, negative } = buildBrokerAllocation(portfolio);
let consumedPercent = 0;
const arcs = sectors.map((sector) => {
const dashOffset = -((consumedPercent / 100) * CIRCUMFERENCE);
const dashLength = (sector.percent / 100) * CIRCUMFERENCE;
consumedPercent += sector.percent;
return { ...sector, dashOffset, dashLength };
});
return (
<figure className="broker-allocation">
<svg role="img" aria-label="Структура брокерского портфеля" viewBox="0 0 120 120">
<title>Структура брокерского портфеля</title>
{arcs.map((sector) => (
<circle
key={sector.key}
cx="60"
cy="60"
r={RADIUS}
fill="none"
stroke={sector.color}
strokeWidth="14"
strokeDasharray={`${sector.dashLength} ${CIRCUMFERENCE - sector.dashLength}`}
strokeDashoffset={sector.dashOffset}
transform="rotate(-90 60 60)"
/>
))}
</svg>
<figcaption>
{sectors.length === 0 ? (
<p>Нет данных для распределения</p>
) : (
<ul>
{sectors.map((sector) => (
<li key={sector.key}>
<span
className="broker-allocation__swatch"
aria-hidden="true"
style={{ background: sector.color }}
/>
<span>
{sector.label}: {formatMoneyValue(sector.value)} · {sector.percent.toFixed(1)}%
</span>
</li>
))}
</ul>
)}
{negative.length > 0 && (
<ul
className="broker-allocation__negative"
aria-label="Отрицательные значения распределения"
>
{negative.map((item) => (
<li key={item.key}>
{item.label}: отрицательное значение {formatMoneyValue(item.value)}
</li>
))}
</ul>
)}
</figcaption>
</figure>
);
}

View File

@ -1,4 +1,5 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { ReactNode } from 'react';
import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../api/responses'; import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../api/responses';
import { import {
getBrokerInstrumentPath, getBrokerInstrumentPath,
@ -94,24 +95,19 @@ const pagButtonDisabledStyle = {
} satisfies React.CSSProperties; } satisfies React.CSSProperties;
export function BrokerOperationsTable({ export function BrokerOperationsTable({
title,
headerAction,
emptyMessage,
isLoading, isLoading,
isFetching, isFetching,
page, page,
pageNumber, pagination,
canGoBack, }: BrokerOperationsTableProps) {
canGoForward, const pageNumber = pagination?.pageNumber;
onPrevious, const canGoBack = pagination?.canGoBack ?? false;
onNext, const canGoForward = pagination?.canGoForward ?? false;
}: { const onPrevious = pagination?.onPrevious;
isLoading: boolean; const onNext = pagination?.onNext;
isFetching: boolean;
page: BrokerOperationsPage | undefined;
pageNumber: number;
canGoBack: boolean;
canGoForward: boolean;
onPrevious: () => void;
onNext: () => void;
}) {
const operations = page?.items ?? []; const operations = page?.items ?? [];
return ( return (
@ -125,7 +121,9 @@ export function BrokerOperationsTable({
marginBottom: 12, marginBottom: 12,
}} }}
> >
<h2 style={{ fontSize: 20, margin: 0 }}>Операции</h2> <h2 style={{ fontSize: 20, margin: 0 }}>{title}</h2>
{headerAction}
{pagination && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button <button
type="button" type="button"
@ -169,6 +167,7 @@ export function BrokerOperationsTable({
)} )}
</button> </button>
</div> </div>
)}
</div> </div>
{isLoading ? ( {isLoading ? (
@ -197,7 +196,7 @@ export function BrokerOperationsTable({
</table> </table>
</div> </div>
) : operations.length === 0 && !isFetching ? ( ) : operations.length === 0 && !isFetching ? (
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p> <p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
) : ( ) : (
<div className="table-container"> <div className="table-container">
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}> <div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
@ -246,7 +245,7 @@ export function BrokerOperationsTable({
<div className="table-loading-overlay"> <div className="table-loading-overlay">
<div className="loading-spinner" /> <div className="loading-spinner" />
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}> <span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
Загрузка страницы {pageNumber} {pagination ? `Загрузка страницы ${pageNumber}` : 'Обновление операций…'}
</span> </span>
</div> </div>
)} )}
@ -255,3 +254,19 @@ export function BrokerOperationsTable({
</section> </section>
); );
} }
type BrokerOperationsTableProps = {
title: string;
headerAction?: ReactNode;
emptyMessage: string;
isLoading: boolean;
isFetching: boolean;
page: BrokerOperationsPage | undefined;
pagination?: {
pageNumber: number;
canGoBack: boolean;
canGoForward: boolean;
onPrevious: () => void;
onNext: () => void;
};
};

View File

@ -8,9 +8,10 @@ import * as accountHook from '../../hooks/useBrokerAccounts';
import * as operationsHook from '../../hooks/useBrokerOperations'; import * as operationsHook from '../../hooks/useBrokerOperations';
import * as portfolioHook from '../../hooks/useBrokerPortfolio'; import * as portfolioHook from '../../hooks/useBrokerPortfolio';
import * as positionsHook from '../../hooks/useBrokerPositions'; import * as positionsHook from '../../hooks/useBrokerPositions';
import type { BrokerPosition } from '../../api/responses'; import type { BrokerPortfolio, BrokerPosition } from '../../api/responses';
import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLayout'; import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLayout';
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage'; import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
import { BrokerAccountOverviewPage } from './BrokerAccountOverviewPage';
import { BrokerAccountsPage } from './BrokerAccountsPage'; import { BrokerAccountsPage } from './BrokerAccountsPage';
function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) { function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) {
@ -43,6 +44,70 @@ function createPosition(input: Partial<BrokerPosition>): BrokerPosition {
}; };
} }
function createOverviewPortfolio(overrides: Partial<BrokerPortfolio> = {}): BrokerPortfolio {
return {
account: {
id: 'acc-1',
type: 'brokerage',
name: 'Основной брокерский счёт',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
positionCounts: { shares: 14, bonds: 8, etf: 2, other: 1 },
totals: {
shares: { currency: 'RUB', units: '750000', nano: 0, value: 750_000 },
bonds: { currency: 'RUB', units: '300000', nano: 0, value: 300_000 },
etf: { currency: 'RUB', units: '50000', nano: 0, value: 50_000 },
currencies: { currency: 'RUB', units: '100000', nano: 0, value: 100_000 },
futures: null,
options: null,
structuredProducts: null,
dfa: null,
portfolio: { currency: 'RUB', units: '1250000', nano: 0, value: 1_250_000 },
},
yields: {
expectedPercent: 12.4,
daily: { currency: 'RUB', units: '1500', nano: 0, value: 1_500 },
dailyPercent: 0.12,
},
cash: [
{ currency: 'RUB', units: '100000', nano: 0, value: 100_000 },
{ currency: 'USD', units: '250', nano: 0, value: 250 },
],
blockedCash: [],
asOf: '2026-06-19T00:00:00.000Z',
...overrides,
};
}
function mockOverviewOperations(overrides: Record<string, unknown> = {}) {
return vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
...overrides,
} as any);
}
function renderOverview() {
return renderWithClient(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountLayout />}>
<Route index element={<BrokerAccountOverviewPage />} />
</Route>
</Routes>,
['/broker/acc-1'],
);
}
/** Spy on useBrokerPositions and return only positions matching query.type . */ /** Spy on useBrokerPositions and return only positions matching query.type . */
function mockUseBrokerPositions(...positions: BrokerPosition[]) { function mockUseBrokerPositions(...positions: BrokerPosition[]) {
return vi.spyOn(positionsHook, 'useBrokerPositions').mockImplementation((_accountId, query) => { return vi.spyOn(positionsHook, 'useBrokerPositions').mockImplementation((_accountId, query) => {
@ -622,4 +687,216 @@ describe('Broker pages', () => {
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
expect(withinOperations.getByText('1')).toBeInTheDocument(); expect(withinOperations.getByText('1')).toBeInTheDocument();
}); });
it('renders the broker account overview with allocation, asset links and recent operations', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: createOverviewPortfolio(),
isLoading: false,
isFetching: false,
error: null,
} as any);
const operationsSpy = mockOverviewOperations({
data: {
accountId: 'acc-1',
items: [
{
cursor: 'recent-1',
accountId: 'acc-1',
id: 'recent-1',
parentOperationId: null,
date: '2026-06-18T10:00:00.000Z',
category: 'income',
type: 'OPERATION_TYPE_COUPON',
description: 'Coupon',
name: 'Купон ОФЗ',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'bond-uid',
figi: null,
ticker: 'SU26238RMFS5',
classCode: 'TQOB',
instrumentType: 'bond',
payment: { currency: 'RUB', units: '120', nano: 0, value: 120 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: 2,
quantityDone: 2,
},
],
nextCursor: null,
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
},
});
renderOverview();
expect(screen.getByText(/1[\s\u00a0]?250[\s\u00a0]?000/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Акции.*14 позиций/i })).toHaveAttribute(
'href',
'/broker/acc-1/shares',
);
expect(screen.getByRole('link', { name: /Облигации.*8 выпусков/i })).toHaveAttribute(
'href',
'/broker/acc-1/bonds',
);
const allocationChart = screen.getByRole('img', {
name: 'Структура брокерского портфеля',
});
expect(allocationChart).toBeInTheDocument();
expect(screen.getByTitle('Структура брокерского портфеля')).toBeInTheDocument();
const circumference = 2 * Math.PI * 44;
const allocationArcs = allocationChart.querySelectorAll('circle');
expect(allocationArcs[0]).toHaveAttribute(
'stroke-dasharray',
`${circumference * 0.6} ${circumference - circumference * 0.6}`,
);
expect(allocationArcs[1]).toHaveAttribute('stroke-dashoffset', `${-circumference * 0.6}`);
expect(screen.getByText(/Акции:.*750[\s\u00a0]?000.*60\.0%/)).toBeInTheDocument();
expect(document.querySelector('.broker-allocation__swatch')).toHaveAttribute(
'aria-hidden',
'true',
);
expect(screen.getByRole('link', { name: 'Вся история' })).toHaveAttribute(
'href',
'/broker/acc-1/operations',
);
expect(operationsSpy).toHaveBeenCalledWith('acc-1', { limit: 5 });
expect(screen.queryByRole('heading', { name: 'Позиции' })).not.toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: 'Количество' })).not.toBeInTheDocument();
});
it.each([
[1, '1 позиция', '1 выпуск'],
[2, '2 позиции', '2 выпуска'],
[5, '5 позиций', '5 выпусков'],
])('uses Russian asset count plurals for %i', (count, sharesText, bondsText) => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: createOverviewPortfolio({
positionCounts: { shares: count, bonds: count, etf: 0, other: 0 },
}),
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
expect(
screen.getByRole('link', { name: new RegExp(`Акции.*${sharesText}`) }),
).toBeInTheDocument();
expect(
screen.getByRole('link', { name: new RegExp(`Облигации.*${bondsText}`) }),
).toBeInTheDocument();
});
it('renders an overview skeleton while the portfolio is loading', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: undefined,
isLoading: true,
isFetching: true,
error: null,
} as any);
mockOverviewOperations();
const { container } = renderOverview();
expect(container.querySelector('.broker-overview')).toBeInTheDocument();
expect(container.querySelectorAll('.skeleton').length).toBeGreaterThan(0);
expect(
screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
).toBeInTheDocument();
});
it('keeps account navigation visible when the overview portfolio fails', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: new Error('portfolio failed'),
} as any);
mockOverviewOperations();
renderOverview();
expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить сводку счёта');
expect(
screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
).toBeInTheDocument();
});
it('keeps the overview summary and navigation when recent operations fail', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: createOverviewPortfolio(),
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations({ data: undefined, error: new Error('operations failed') });
renderOverview();
expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить последние операции');
expect(screen.getByText(/1[\s\u00a0]?250[\s\u00a0]?000/)).toBeInTheDocument();
expect(
screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
).toBeInTheDocument();
});
it('renders the overview recent-operations empty state and history link', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: createOverviewPortfolio(),
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
expect(screen.getByText('Операций с начала текущего года нет')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Вся история' })).toHaveAttribute(
'href',
'/broker/acc-1/operations',
);
});
it('renders negative allocation values as text instead of chart sectors', () => {
const portfolio = createOverviewPortfolio();
portfolio.totals.bonds = { currency: 'RUB', units: '-10000', nano: 0, value: -10_000 };
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: portfolio,
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
const negativeList = screen.getByRole('list', {
name: 'Отрицательные значения распределения',
});
expect(
within(negativeList).getByText(/Облигации: отрицательное значение.*10[\s\u00a0]?000/),
).toBeInTheDocument();
});
it('renders an empty allocation state when the portfolio total has no allocation data', () => {
const portfolio = createOverviewPortfolio();
portfolio.totals.portfolio = { currency: 'RUB', units: '0', nano: 0, value: 0 };
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: portfolio,
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
expect(screen.getByText('Нет данных для распределения')).toBeInTheDocument();
});
}); });

View File

@ -115,6 +115,103 @@ a {
min-width: 0; min-width: 0;
} }
.broker-overview {
display: grid;
gap: 24px;
}
.broker-overview__summary,
.broker-overview__assets {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.broker-overview__card,
.broker-allocation {
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: var(--border-radius);
background: var(--color-surface);
}
.broker-overview__card {
display: grid;
align-content: start;
gap: 8px;
}
.broker-overview__label,
.broker-overview__card > span {
color: var(--color-text-secondary);
}
.broker-overview__total {
font-size: 24px;
}
.broker-overview__cash,
.broker-allocation ul {
list-style: none;
display: grid;
gap: 8px;
}
.broker-overview__cash li {
display: flex;
justify-content: space-between;
gap: 12px;
}
.broker-overview__asset-link {
color: var(--color-text);
}
.broker-overview__asset-title {
color: var(--color-primary);
font-size: 18px;
}
.broker-overview__asset-link:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-primary) 35%, transparent);
outline-offset: 2px;
}
.broker-allocation {
display: flex;
align-items: center;
gap: 20px;
}
.broker-allocation svg {
width: 160px;
max-width: 40%;
flex: 0 0 auto;
}
.broker-allocation figcaption {
display: grid;
gap: 12px;
}
.broker-allocation li {
display: flex;
align-items: center;
gap: 8px;
}
.broker-allocation__swatch {
width: 12px;
height: 12px;
border: 1px solid color-mix(in srgb, var(--color-text) 20%, transparent);
border-radius: 2px;
flex: 0 0 auto;
}
.broker-allocation__negative {
color: var(--color-negative);
}
@media (max-width: 720px) { @media (max-width: 720px) {
.broker-account__workspace { .broker-account__workspace {
gap: 16px; gap: 16px;
@ -131,4 +228,20 @@ a {
flex: none; flex: none;
white-space: nowrap; white-space: nowrap;
} }
.broker-overview__summary,
.broker-overview__assets {
grid-template-columns: 1fr;
}
.broker-allocation {
align-items: stretch;
flex-direction: column;
}
.broker-allocation svg {
max-width: 180px;
width: 100%;
align-self: center;
}
} }