Compare commits
19 Commits
3534ec4f77
...
6c768ef6a9
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c768ef6a9 | |||
| 5ea1fcfe14 | |||
| 1cabe2d71e | |||
| 6debf66ac8 | |||
| b41ee9fce4 | |||
| 85fbfcdb0c | |||
| aa56c8a48d | |||
| 8ad5f3c70c | |||
| e044b64de4 | |||
| da656330b3 | |||
| 9a94f4e3a9 | |||
| a9a95dae69 | |||
| ef97fd7136 | |||
| 3682f43aa6 | |||
| bd27bba66d | |||
| 860bed159c | |||
| 62a3389bfb | |||
| a8741cfeab | |||
| bd33412691 |
@ -32,7 +32,16 @@ module.exports = {
|
|||||||
'no-restricted-imports': ['warn', {
|
'no-restricted-imports': ['warn', {
|
||||||
paths: [{
|
paths: [{
|
||||||
name: '@mui/material',
|
name: '@mui/material',
|
||||||
message: 'Import from @moex-vibe/design-system instead.',
|
importNames: [
|
||||||
|
// DS-covered: import from @moex-vibe/design-system
|
||||||
|
'Typography', 'Button', 'TextField', 'Select', 'Checkbox',
|
||||||
|
'Paper', 'Chip', 'Badge', 'Alert', 'Dialog', 'Skeleton',
|
||||||
|
'CircularProgress', 'Link', 'IconButton',
|
||||||
|
'Table', 'TableBody', 'TableCell', 'TableContainer',
|
||||||
|
'TableHead', 'TableRow', 'TableSortLabel',
|
||||||
|
'TablePagination', 'Pagination',
|
||||||
|
],
|
||||||
|
message: 'Import from @moex-vibe/design-system instead, or use Box/Stack/Grid for layout.',
|
||||||
}],
|
}],
|
||||||
}],
|
}],
|
||||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { Text } from '@moex-vibe/design-system';
|
||||||
import { useBrokerOperations } from '@/entities/broker-operation';
|
import { useBrokerOperations } from '@/entities/broker-operation';
|
||||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
||||||
import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart';
|
import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart';
|
||||||
@ -11,16 +13,22 @@ export function BrokerAccountOverviewPage() {
|
|||||||
|
|
||||||
if (portfolio.isLoading) return <BrokerOverviewSkeleton />;
|
if (portfolio.isLoading) return <BrokerOverviewSkeleton />;
|
||||||
if (portfolio.error || !portfolio.data) {
|
if (portfolio.error || !portfolio.data) {
|
||||||
return <p role="alert">Не удалось загрузить сводку счёта</p>;
|
return (
|
||||||
|
<Text component="p" role="alert" tone="negative">
|
||||||
|
Не удалось загрузить сводку счёта
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="broker-overview">
|
<Box component="div" sx={{ display: 'grid', gap: 3 }}>
|
||||||
<BrokerSummary portfolio={portfolio.data} />
|
<BrokerSummary portfolio={portfolio.data} />
|
||||||
<BrokerAllocationChart portfolio={portfolio.data} />
|
<BrokerAllocationChart portfolio={portfolio.data} />
|
||||||
<BrokerAssetCards accountId={accountId} portfolio={portfolio.data} />
|
<BrokerAssetCards accountId={accountId} portfolio={portfolio.data} />
|
||||||
{operations.error ? (
|
{operations.error ? (
|
||||||
<p role="alert">Не удалось загрузить последние операции</p>
|
<Text component="p" role="alert" tone="negative">
|
||||||
|
Не удалось загрузить последние операции
|
||||||
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<BrokerOperationsTable
|
<BrokerOperationsTable
|
||||||
title="Последние операции"
|
title="Последние операции"
|
||||||
@ -33,6 +41,6 @@ export function BrokerAccountOverviewPage() {
|
|||||||
page={operations.data}
|
page={operations.data}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { EmptyState, Heading, Text } from '@moex-vibe/design-system';
|
||||||
import {
|
import {
|
||||||
aggregateBrokerAccounts,
|
aggregateBrokerAccounts,
|
||||||
useBrokerAccounts,
|
useBrokerAccounts,
|
||||||
@ -8,20 +10,20 @@ import { BrokerAccountsSummary } from '@/widgets/broker-accounts-summary';
|
|||||||
|
|
||||||
function BrokerAccountsPageSkeleton() {
|
function BrokerAccountsPageSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="broker-accounts-page">
|
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||||
<header className="broker-accounts-page__header">
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||||
<div>
|
<Text tone="secondary" variant="label">
|
||||||
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
|
T-Bank broker overview
|
||||||
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
|
</Text>
|
||||||
</div>
|
<Heading level={1}>Брокерские счета</Heading>
|
||||||
</header>
|
</Box>
|
||||||
<BrokerAccountsSummary
|
<BrokerAccountsSummary
|
||||||
aggregate={{ portfolios: [], cash: [] }}
|
aggregate={{ portfolios: [], cash: [] }}
|
||||||
availableCount={0}
|
availableCount={0}
|
||||||
totalCount={0}
|
totalCount={0}
|
||||||
isLoading
|
isLoading
|
||||||
/>
|
/>
|
||||||
<div className="broker-accounts-page__cards">
|
<Box sx={{ display: 'grid', gap: 2 }}>
|
||||||
{['1', '2', '3'].map((id) => (
|
{['1', '2', '3'].map((id) => (
|
||||||
<BrokerAccountCard
|
<BrokerAccountCard
|
||||||
key={id}
|
key={id}
|
||||||
@ -38,8 +40,8 @@ function BrokerAccountsPageSkeleton() {
|
|||||||
onRetry={() => undefined}
|
onRetry={() => undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,25 +55,23 @@ export function BrokerAccountsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить счета</p>;
|
return <Text tone="negative">Не удалось загрузить счета</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (safeAccounts.length === 0) {
|
if (safeAccounts.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="broker-accounts-page">
|
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||||
<header className="broker-accounts-page__header">
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||||
<div>
|
<Text tone="secondary" variant="label">
|
||||||
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
|
T-Bank broker overview
|
||||||
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
|
</Text>
|
||||||
</div>
|
<Heading level={1}>Брокерские счета</Heading>
|
||||||
</header>
|
</Box>
|
||||||
<section className="broker-accounts-empty">
|
<EmptyState
|
||||||
<h2>Пока нет подключённых счетов</h2>
|
title="Пока нет подключённых счетов"
|
||||||
<p>
|
description="После подключения T-Bank здесь появятся брокерские счета и ИИС со сводкой по капиталу."
|
||||||
После подключения T-Bank здесь появятся брокерские счета и ИИС со сводкой по капиталу.
|
/>
|
||||||
</p>
|
</Box>
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,16 +85,16 @@ export function BrokerAccountsPage() {
|
|||||||
const aggregate = aggregateBrokerAccounts(successfulPortfolios);
|
const aggregate = aggregateBrokerAccounts(successfulPortfolios);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="broker-accounts-page">
|
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||||
<header className="broker-accounts-page__header">
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, alignItems: 'end' }}>
|
||||||
<div>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||||
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
|
<Text tone="secondary" variant="label">
|
||||||
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
|
T-Bank broker overview
|
||||||
</div>
|
</Text>
|
||||||
<p className="broker-accounts-page__caption">
|
<Heading level={1}>Брокерские счета</Heading>
|
||||||
{safeAccounts.length} счетов под наблюдением
|
</Box>
|
||||||
</p>
|
<Text tone="secondary">{safeAccounts.length} счетов под наблюдением</Text>
|
||||||
</header>
|
</Box>
|
||||||
|
|
||||||
<BrokerAccountsSummary
|
<BrokerAccountsSummary
|
||||||
aggregate={aggregate}
|
aggregate={aggregate}
|
||||||
@ -103,7 +103,7 @@ export function BrokerAccountsPage() {
|
|||||||
isLoading={availableCount === 0 && loadingCount > 0}
|
isLoading={availableCount === 0 && loadingCount > 0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="broker-accounts-page__cards">
|
<Box sx={{ display: 'grid', gap: 2 }}>
|
||||||
{accountQueries.map(({ account, query }) => (
|
{accountQueries.map(({ account, query }) => (
|
||||||
<BrokerAccountCard
|
<BrokerAccountCard
|
||||||
key={account.id}
|
key={account.id}
|
||||||
@ -116,7 +116,7 @@ export function BrokerAccountsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { Heading, Text } from '@moex-vibe/design-system';
|
||||||
import {
|
import {
|
||||||
BROKER_OPERATION_TYPE_OPTIONS,
|
BROKER_OPERATION_TYPE_OPTIONS,
|
||||||
isBrokerOperationType,
|
isBrokerOperationType,
|
||||||
@ -32,7 +34,9 @@ export function BrokerOperationsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const history = operations.error ? (
|
const history = operations.error ? (
|
||||||
<p role="alert">Не удалось загрузить историю операций</p>
|
<Text component="p" tone="negative" role="alert">
|
||||||
|
Не удалось загрузить историю операций
|
||||||
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<BrokerOperationsTable
|
<BrokerOperationsTable
|
||||||
title="История операций"
|
title="История операций"
|
||||||
@ -53,13 +57,15 @@ export function BrokerOperationsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="broker-operations-heading">
|
<Box component="section" aria-labelledby="broker-operations-heading">
|
||||||
<div className="broker-operations__toolbar">
|
<Box
|
||||||
<h2 id="broker-operations-heading" style={{ fontSize: 20, margin: 0 }}>
|
sx={{ display: 'flex', alignItems: 'end', justifyContent: 'space-between', gap: 2, mb: 2 }}
|
||||||
|
>
|
||||||
|
<Heading level={2} id="broker-operations-heading">
|
||||||
Операции
|
Операции
|
||||||
</h2>
|
</Heading>
|
||||||
<label>
|
<Box component="label" sx={{ display: 'grid', gap: 0.5, color: 'text.secondary' }}>
|
||||||
<span>Тип операции</span>
|
<Text variant="label">Тип операции</Text>
|
||||||
<select value={selectedType} onChange={handleTypeChange}>
|
<select value={selectedType} onChange={handleTypeChange}>
|
||||||
<option value="">Все операции</option>
|
<option value="">Все операции</option>
|
||||||
{BROKER_OPERATION_TYPE_OPTIONS.map((option) => (
|
{BROKER_OPERATION_TYPE_OPTIONS.map((option) => (
|
||||||
@ -68,9 +74,9 @@ export function BrokerOperationsPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
{history}
|
{history}
|
||||||
</section>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { Heading, Text } from '@moex-vibe/design-system';
|
||||||
import { useBrokerPositions } from '@/entities/broker-position';
|
import { useBrokerPositions } from '@/entities/broker-position';
|
||||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
||||||
import { BrokerPositionTable } from '@/widgets/broker-positions-table';
|
import { BrokerPositionTable } from '@/widgets/broker-positions-table';
|
||||||
@ -15,14 +17,14 @@ export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
|
|||||||
|
|
||||||
if (positions.error) {
|
if (positions.error) {
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby={`broker-${type}-heading`}>
|
<Box component="section" aria-labelledby={`broker-${type}-heading`}>
|
||||||
<h2 id={`broker-${type}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
<Heading level={2} id={`broker-${type}-heading`}>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</Heading>
|
||||||
<p role="alert">
|
<Text component="p" tone="negative" role="alert">
|
||||||
{type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'}
|
{type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'}
|
||||||
</p>
|
</Text>
|
||||||
</section>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,15 @@
|
|||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { Heading, Text } from '@moex-vibe/design-system';
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', paddingTop: 120 }}>
|
<Box textAlign="center" pt={30}>
|
||||||
<h1 style={{ fontSize: 32, fontWeight: 700, marginBottom: 12 }}>MoexVibe</h1>
|
<Heading level={1} size="display">
|
||||||
<p style={{ color: 'var(--color-text-secondary)', fontSize: 16, marginBottom: 32 }}>
|
MoexVibe
|
||||||
Анализ акций и облигаций Московской биржи
|
</Heading>
|
||||||
</p>
|
<Text tone="secondary">Анализ акций и облигаций Московской биржи</Text>
|
||||||
<p style={{ color: '#888', fontSize: 13 }}>Введите название или тикер в строку поиска выше</p>
|
<Text tone="muted">Введите название или тикер в строку поиска выше</Text>
|
||||||
<p style={{ color: '#aaa', fontSize: 12, marginTop: 8 }}>
|
<Text tone="muted">Данные задерживаются на 15 минут · Бесплатный API MOEX ISS</Text>
|
||||||
Данные задерживаются на 15 минут · Бесплатный API MOEX ISS
|
</Box>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { screen } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
@ -72,6 +72,8 @@ describe('LoginPage', () => {
|
|||||||
await user.type(screen.getByPlaceholderText('••••••••'), 'password');
|
await user.type(screen.getByPlaceholderText('••••••••'), 'password');
|
||||||
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||||
|
|
||||||
expect(await screen.findByText('Вход...')).toBeInTheDocument();
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: 'Войти' })).toBeDisabled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { Button, Heading, Text, TextField } from '@moex-vibe/design-system';
|
||||||
import { useSession } from '@/entities/session';
|
import { useSession } from '@/entities/session';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
@ -28,84 +30,44 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 400, margin: '60px auto' }}>
|
<Box maxWidth={400} mx="auto" mt={7.5}>
|
||||||
<h1 style={{ marginBottom: 24, fontSize: 24, fontWeight: 700 }}>Вход</h1>
|
<Heading level={1} size="title">
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
Вход
|
||||||
{error && <div style={{ color: 'var(--color-negative)', fontSize: 14 }}>{error}</div>}
|
</Heading>
|
||||||
<div>
|
<Box
|
||||||
<label
|
component="form"
|
||||||
style={{
|
onSubmit={handleSubmit}
|
||||||
display: 'block',
|
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 3 }}
|
||||||
marginBottom: 4,
|
>
|
||||||
fontSize: 14,
|
{error && <Text tone="negative">{error}</Text>}
|
||||||
color: 'var(--color-text-secondary)',
|
<TextField
|
||||||
}}
|
label="Email"
|
||||||
>
|
type="email"
|
||||||
Email
|
value={email}
|
||||||
</label>
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
<input
|
required
|
||||||
type="email"
|
placeholder="email@example.com"
|
||||||
value={email}
|
/>
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
<TextField
|
||||||
required
|
label="Пароль"
|
||||||
style={inputStyle}
|
type="password"
|
||||||
placeholder="email@example.com"
|
value={password}
|
||||||
/>
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
</div>
|
required
|
||||||
<div>
|
placeholder="••••••••"
|
||||||
<label
|
/>
|
||||||
style={{
|
<Button type="submit" loading={loading}>
|
||||||
display: 'block',
|
Войти
|
||||||
marginBottom: 4,
|
</Button>
|
||||||
fontSize: 14,
|
<Text tone="secondary" style={{ textAlign: 'center' }}>
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Пароль
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
style={inputStyle}
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button type="submit" disabled={loading} style={buttonStyle}>
|
|
||||||
{loading ? 'Вход...' : 'Войти'}
|
|
||||||
</button>
|
|
||||||
<p style={{ textAlign: 'center', fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
|
||||||
Нет аккаунта?{' '}
|
Нет аккаунта?{' '}
|
||||||
<Link
|
<Link
|
||||||
to={`/register${redirect !== '/' ? `?redirect=${encodeURIComponent(redirect)}` : ''}`}
|
to={`/register${redirect !== '/' ? `?redirect=${encodeURIComponent(redirect)}` : ''}`}
|
||||||
style={{ color: 'var(--color-primary)' }}
|
|
||||||
>
|
>
|
||||||
Зарегистрироваться
|
Зарегистрироваться
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</Text>
|
||||||
</form>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
|
||||||
width: '100%',
|
|
||||||
padding: '10px 12px',
|
|
||||||
border: '1px solid #e0e0e0',
|
|
||||||
borderRadius: 'var(--border-radius)',
|
|
||||||
fontSize: 16,
|
|
||||||
outline: 'none',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
};
|
|
||||||
|
|
||||||
const buttonStyle: React.CSSProperties = {
|
|
||||||
padding: '12px 24px',
|
|
||||||
background: 'var(--color-primary)',
|
|
||||||
color: '#fff',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--border-radius)',
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: 'pointer',
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { screen } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { server } from '@/shared/lib/test/server';
|
||||||
@ -57,6 +57,8 @@ describe('ProfilePage', () => {
|
|||||||
await user.type(input, 'New Name');
|
await user.type(input, 'New Name');
|
||||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
|
||||||
expect(await screen.findByText('Сохранение...')).toBeInTheDocument();
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: 'Сохранить' })).toBeDisabled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { Button, Heading, Surface, Text, TextField } from '@moex-vibe/design-system';
|
||||||
import { useSession } from '@/entities/session';
|
import { useSession } from '@/entities/session';
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
@ -24,82 +26,37 @@ export function ProfilePage() {
|
|||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 500, margin: '40px auto' }}>
|
<Box maxWidth={500} mx="auto" mt={5}>
|
||||||
<h1 style={{ marginBottom: 24, fontSize: 24, fontWeight: 700 }}>Профиль</h1>
|
<Heading level={1} size="title">
|
||||||
<div
|
Профиль
|
||||||
style={{
|
</Heading>
|
||||||
background: 'var(--color-surface)',
|
<Surface elevation="sm" padding="md">
|
||||||
borderRadius: 'var(--border-radius)',
|
<Box sx={{ mb: 2 }}>
|
||||||
boxShadow: 'var(--shadow)',
|
<Text variant="label" tone="secondary">
|
||||||
padding: 24,
|
Почта
|
||||||
}}
|
</Text>
|
||||||
>
|
<Text>{user.email}</Text>
|
||||||
<div style={{ marginBottom: 16 }}>
|
</Box>
|
||||||
<span style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>Почта</span>
|
<Box sx={{ mb: 2 }}>
|
||||||
<p style={{ fontSize: 16, fontWeight: 500 }}>{user.email}</p>
|
<Text variant="label" tone="secondary">
|
||||||
</div>
|
Роль
|
||||||
<div style={{ marginBottom: 16 }}>
|
</Text>
|
||||||
<span style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>Роль</span>
|
<Text>{user.role}</Text>
|
||||||
<p style={{ fontSize: 16, fontWeight: 500 }}>{user.role}</p>
|
</Box>
|
||||||
</div>
|
<Box
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
component="form"
|
||||||
<div>
|
onSubmit={handleSubmit}
|
||||||
<label
|
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
||||||
style={{
|
>
|
||||||
display: 'block',
|
<TextField label="Имя" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
marginBottom: 4,
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Имя
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '10px 12px',
|
|
||||||
border: '1px solid #e0e0e0',
|
|
||||||
borderRadius: 'var(--border-radius)',
|
|
||||||
fontSize: 16,
|
|
||||||
outline: 'none',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{message && (
|
{message && (
|
||||||
<div
|
<Text tone={message === 'Профиль обновлён' ? 'positive' : 'negative'}>{message}</Text>
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
color:
|
|
||||||
message === 'Профиль обновлён'
|
|
||||||
? 'var(--color-positive)'
|
|
||||||
: 'var(--color-negative)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<button
|
<Button type="submit" loading={saving} variant="primary">
|
||||||
type="submit"
|
Сохранить
|
||||||
disabled={saving}
|
</Button>
|
||||||
style={{
|
</Box>
|
||||||
padding: '10px 20px',
|
</Surface>
|
||||||
background: 'var(--color-primary)',
|
</Box>
|
||||||
color: '#fff',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--border-radius)',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: 'pointer',
|
|
||||||
alignSelf: 'flex-start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { Button, Heading, Text, TextField } from '@moex-vibe/design-system';
|
||||||
import { useSession } from '@/entities/session';
|
import { useSession } from '@/entities/session';
|
||||||
|
|
||||||
export function RegisterPage() {
|
export function RegisterPage() {
|
||||||
@ -36,124 +38,57 @@ export function RegisterPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 400, margin: '60px auto' }}>
|
<Box maxWidth={400} mx="auto" mt={7.5}>
|
||||||
<h1 style={{ marginBottom: 24, fontSize: 24, fontWeight: 700 }}>Регистрация</h1>
|
<Heading level={1} size="title">
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
Регистрация
|
||||||
{error && <div style={{ color: 'var(--color-negative)', fontSize: 14 }}>{error}</div>}
|
</Heading>
|
||||||
<div>
|
<Box
|
||||||
<label
|
component="form"
|
||||||
style={{
|
onSubmit={handleSubmit}
|
||||||
display: 'block',
|
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 3 }}
|
||||||
marginBottom: 4,
|
>
|
||||||
fontSize: 14,
|
{error && <Text tone="negative">{error}</Text>}
|
||||||
color: 'var(--color-text-secondary)',
|
<TextField
|
||||||
}}
|
label="Имя (необязательно)"
|
||||||
>
|
value={name}
|
||||||
Имя (необязательно)
|
onChange={(e) => setName(e.target.value)}
|
||||||
</label>
|
placeholder="Иван Иванов"
|
||||||
<input
|
/>
|
||||||
type="text"
|
<TextField
|
||||||
value={name}
|
label="Email"
|
||||||
onChange={(e) => setName(e.target.value)}
|
type="email"
|
||||||
style={inputStyle}
|
value={email}
|
||||||
placeholder="Иван Иванов"
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
/>
|
required
|
||||||
</div>
|
placeholder="email@example.com"
|
||||||
<div>
|
/>
|
||||||
<label
|
<TextField
|
||||||
style={{
|
label="Пароль"
|
||||||
display: 'block',
|
type="password"
|
||||||
marginBottom: 4,
|
value={password}
|
||||||
fontSize: 14,
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
color: 'var(--color-text-secondary)',
|
required
|
||||||
}}
|
placeholder="Минимум 6 символов"
|
||||||
>
|
slotProps={{ htmlInput: { minLength: 6 } }}
|
||||||
Email
|
/>
|
||||||
</label>
|
<TextField
|
||||||
<input
|
label="Подтверждение пароля"
|
||||||
type="email"
|
type="password"
|
||||||
value={email}
|
value={confirmPassword}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
style={inputStyle}
|
placeholder="Повторите пароль"
|
||||||
placeholder="email@example.com"
|
/>
|
||||||
/>
|
<Button type="submit" loading={loading}>
|
||||||
</div>
|
Зарегистрироваться
|
||||||
<div>
|
</Button>
|
||||||
<label
|
<Text tone="secondary" style={{ textAlign: 'center' }}>
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
marginBottom: 4,
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Пароль
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
minLength={6}
|
|
||||||
style={inputStyle}
|
|
||||||
placeholder="Минимум 6 символов"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
marginBottom: 4,
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Подтверждение пароля
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
style={inputStyle}
|
|
||||||
placeholder="Повторите пароль"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button type="submit" disabled={loading} style={buttonStyle}>
|
|
||||||
{loading ? 'Регистрация...' : 'Зарегистрироваться'}
|
|
||||||
</button>
|
|
||||||
<p style={{ textAlign: 'center', fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
|
||||||
Уже есть аккаунт?{' '}
|
Уже есть аккаунт?{' '}
|
||||||
<Link
|
<Link to={`/login${redirect !== '/' ? `?redirect=${encodeURIComponent(redirect)}` : ''}`}>
|
||||||
to={`/login${redirect !== '/' ? `?redirect=${encodeURIComponent(redirect)}` : ''}`}
|
|
||||||
style={{ color: 'var(--color-primary)' }}
|
|
||||||
>
|
|
||||||
Войти
|
Войти
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</Text>
|
||||||
</form>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
|
||||||
width: '100%',
|
|
||||||
padding: '10px 12px',
|
|
||||||
border: '1px solid #e0e0e0',
|
|
||||||
borderRadius: 'var(--border-radius)',
|
|
||||||
fontSize: 16,
|
|
||||||
outline: 'none',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
};
|
|
||||||
|
|
||||||
const buttonStyle: React.CSSProperties = {
|
|
||||||
padding: '12px 24px',
|
|
||||||
background: 'var(--color-primary)',
|
|
||||||
color: '#fff',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--border-radius)',
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: 'pointer',
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
export function SkeletonBlock({
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
borderRadius = 4,
|
|
||||||
}: {
|
|
||||||
width?: string | number;
|
|
||||||
height?: string | number;
|
|
||||||
borderRadius?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="skeleton"
|
|
||||||
style={{
|
|
||||||
width: width ?? '100%',
|
|
||||||
height: height ?? 16,
|
|
||||||
borderRadius,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
|
import { Skeleton } from '@moex-vibe/design-system';
|
||||||
|
|
||||||
const tdStyle = {
|
const tdStyle = {
|
||||||
borderBottom: '1px solid #eeeeee',
|
borderBottom: '1px solid #eeeeee',
|
||||||
@ -15,7 +15,7 @@ export function TableSkeleton({ rows = 5, columns }: { rows?: number; columns: C
|
|||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
{columns.map((col, j) => (
|
{columns.map((col, j) => (
|
||||||
<td key={j} style={tdStyle}>
|
<td key={j} style={tdStyle}>
|
||||||
<SkeletonBlock height={12} width={col.width} />
|
<Skeleton height={12} width={col.width} shape="text" />
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { Text } from '@moex-vibe/design-system';
|
||||||
|
|
||||||
type AllocationBarItem = {
|
type AllocationBarItem = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@ -16,34 +19,58 @@ export function BrokerAllocationBar({
|
|||||||
const positiveItems = items.filter((item) => item.value > 0);
|
const positiveItems = items.filter((item) => item.value > 0);
|
||||||
|
|
||||||
if (positiveItems.length === 0) {
|
if (positiveItems.length === 0) {
|
||||||
return <p className="broker-allocation-bar__empty">Нет данных для распределения</p>;
|
return <Text tone="muted">Нет данных для распределения</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="broker-allocation-bar">
|
<Box sx={{ display: 'grid', gap: 1.5 }}>
|
||||||
<div className="broker-allocation-bar__track" role="img" aria-label={title}>
|
<Box
|
||||||
|
role="img"
|
||||||
|
aria-label={title}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
minHeight: 12,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: 'rgba(19,54,38,0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{positiveItems.map((item) => (
|
{positiveItems.map((item) => (
|
||||||
<span
|
<Box
|
||||||
key={item.key}
|
key={item.key}
|
||||||
className="broker-allocation-bar__segment"
|
sx={{ minWidth: 8, width: `${item.percent}%`, background: item.color }}
|
||||||
style={{ width: `${item.percent}%`, background: item.color }}
|
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
<ul className="broker-allocation-bar__legend" aria-label={`${title}: легенда`}>
|
<Box
|
||||||
|
component="ul"
|
||||||
|
aria-label={`${title}: легенда`}
|
||||||
|
sx={{ listStyle: 'none', p: 0, m: 0, display: 'flex', flexWrap: 'wrap', gap: 1 }}
|
||||||
|
>
|
||||||
{positiveItems.map((item) => (
|
{positiveItems.map((item) => (
|
||||||
<li className="broker-allocation-bar__legend-item" key={item.key}>
|
<Box
|
||||||
<span
|
component="li"
|
||||||
className="broker-allocation-bar__swatch"
|
key={item.key}
|
||||||
style={{ background: item.color }}
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: 'rgba(19,54,38,0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{ width: 9, height: 9, borderRadius: 999, background: item.color }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span>{item.label}</span>
|
<Text>{item.label}</Text>
|
||||||
<strong>{item.percent.toFixed(0)}%</strong>
|
<Text>{item.percent.toFixed(0)}%</Text>
|
||||||
</li>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +1 @@
|
|||||||
export { SkeletonBlock } from './SkeletonBlock';
|
|
||||||
export { TableSkeleton } from './TableSkeleton';
|
export { TableSkeleton } from './TableSkeleton';
|
||||||
|
|||||||
@ -16,12 +16,6 @@
|
|||||||
--color-negative: #c62828;
|
--color-negative: #c62828;
|
||||||
--border-radius: 8px;
|
--border-radius: 8px;
|
||||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||||
--broker-overview-bg: linear-gradient(180deg, #f1f5ef 0%, #f7f2e8 100%);
|
|
||||||
--broker-overview-panel: rgba(15, 51, 36, 0.93);
|
|
||||||
--broker-overview-panel-soft: rgba(255, 255, 255, 0.09);
|
|
||||||
--broker-overview-border: rgba(21, 61, 43, 0.12);
|
|
||||||
--broker-overview-accent: #98c484;
|
|
||||||
--broker-overview-gold: #d7b268;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pnl-cell {
|
.pnl-cell {
|
||||||
@ -94,425 +88,7 @@ a {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.broker-account__workspace {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(150px, 190px) minmax(0, 1fr);
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account__navigation {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account__link {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account__link.is-active,
|
|
||||||
.broker-account__link[aria-current='page'] {
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account__link:focus-visible {
|
|
||||||
outline: 3px solid var(--color-primary);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account__content {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-page {
|
|
||||||
display: grid;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-page__header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: end;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-page__eyebrow,
|
|
||||||
.broker-account-card__eyebrow,
|
|
||||||
.broker-accounts-summary__eyebrow {
|
|
||||||
font-size: 12px;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-page__title,
|
|
||||||
.broker-account-card__title,
|
|
||||||
.broker-accounts-summary__title {
|
|
||||||
font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Georgia, serif;
|
|
||||||
line-height: 1.05;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-page__title {
|
|
||||||
font-size: clamp(2.2rem, 3vw, 3rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-page__caption {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary {
|
|
||||||
padding: 28px;
|
|
||||||
border-radius: 28px;
|
|
||||||
background: var(--broker-overview-bg);
|
|
||||||
box-shadow:
|
|
||||||
0 24px 60px rgba(15, 52, 35, 0.08),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.55);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
|
||||||
display: grid;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary__hero {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: 22px;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top right, rgba(152, 196, 132, 0.3), transparent 28%),
|
|
||||||
linear-gradient(135deg, var(--broker-overview-panel) 0%, #173f2d 100%);
|
|
||||||
color: #f8f5ec;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary__hero .broker-accounts-summary__eyebrow {
|
|
||||||
color: rgba(248, 245, 236, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary__title {
|
|
||||||
font-size: clamp(1.9rem, 2.4vw, 2.6rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary__status {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
color: rgba(248, 245, 236, 0.78);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary__status span {
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--broker-overview-panel-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary__currency-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary__currency-card,
|
|
||||||
.broker-account-card,
|
|
||||||
.broker-accounts-empty {
|
|
||||||
border-radius: 24px;
|
|
||||||
border: 1px solid var(--broker-overview-border);
|
|
||||||
background: rgba(255, 255, 255, 0.92);
|
|
||||||
box-shadow: 0 20px 45px rgba(31, 48, 39, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary__currency-card {
|
|
||||||
padding: 22px;
|
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary__currency-header {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary__currency {
|
|
||||||
font-size: 13px;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary__total {
|
|
||||||
font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Georgia, serif;
|
|
||||||
font-size: clamp(1.8rem, 2vw, 2.4rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary__metrics {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary__metric {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary__metric dt,
|
|
||||||
.broker-account-card__stat span {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary__metric dd,
|
|
||||||
.broker-account-card__stat strong {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-allocation-bar {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-allocation-bar__track {
|
|
||||||
min-height: 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
background: rgba(19, 54, 38, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-allocation-bar__segment {
|
|
||||||
min-width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-allocation-bar__legend {
|
|
||||||
list-style: none;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-allocation-bar__legend-item {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(19, 54, 38, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-allocation-bar__swatch {
|
|
||||||
width: 9px;
|
|
||||||
height: 9px;
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-allocation-bar__empty {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-page__cards {
|
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account-card {
|
|
||||||
padding: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account-card--link {
|
|
||||||
display: grid;
|
|
||||||
gap: 18px;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
transition:
|
|
||||||
transform 0.22s ease,
|
|
||||||
box-shadow 0.22s ease,
|
|
||||||
border-color 0.22s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account-card--link:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 26px 50px rgba(31, 48, 39, 0.11);
|
|
||||||
border-color: rgba(38, 92, 55, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account-card--link:focus-visible {
|
|
||||||
outline: 3px solid rgba(59, 128, 74, 0.3);
|
|
||||||
outline-offset: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account-card__header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account-card__title {
|
|
||||||
font-size: clamp(1.4rem, 2vw, 1.8rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account-card__opened {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account-card__grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account-card__stat {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: 18px;
|
|
||||||
background:
|
|
||||||
linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(241, 245, 239, 0.88));
|
|
||||||
border: 1px solid rgba(31, 48, 39, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account-card__stat--wide {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account-card__alert {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 18px;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: rgba(198, 40, 40, 0.08);
|
|
||||||
color: #8e2525;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account-card__retry {
|
|
||||||
border: none;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #163f2c;
|
|
||||||
color: #fff;
|
|
||||||
padding: 10px 16px;
|
|
||||||
font: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account-card__retry:focus-visible {
|
|
||||||
outline: 3px solid rgba(59, 128, 74, 0.3);
|
|
||||||
outline-offset: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-empty {
|
|
||||||
padding: 28px;
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.broker-account-card--link,
|
|
||||||
.loading-spinner,
|
.loading-spinner,
|
||||||
.skeleton {
|
.skeleton {
|
||||||
transition: none;
|
transition: none;
|
||||||
@ -520,112 +96,4 @@ a {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.broker-account__header {
|
|
||||||
padding: 0 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account__workspace {
|
|
||||||
gap: 16px;
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account__navigation {
|
|
||||||
flex-direction: row;
|
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account__link {
|
|
||||||
flex: none;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-page__header,
|
|
||||||
.broker-account-card__header {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
display: grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary,
|
|
||||||
.broker-account-card,
|
|
||||||
.broker-accounts-empty {
|
|
||||||
padding: 18px;
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-accounts-summary__hero,
|
|
||||||
.broker-accounts-summary__metrics,
|
|
||||||
.broker-account-card__grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account-card__stat--wide {
|
|
||||||
grid-column: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account-card__opened {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-account-card__alert {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-operations__toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-operations__toolbar label {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-operations__toolbar select {
|
|
||||||
min-width: 240px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid #d8d8d8;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.broker-operations__toolbar select:focus-visible {
|
|
||||||
outline: 3px solid color-mix(in srgb, var(--color-primary) 35%, transparent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.broker-operations__toolbar {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.broker-operations__toolbar select {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
|
import { Box } from '@mui/material';
|
||||||
|
import { Alert, Button, Heading, Skeleton, Text } from '@moex-vibe/design-system';
|
||||||
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses';
|
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses';
|
||||||
import { buildBrokerAllocation } from '@/entities/broker-position';
|
import { buildBrokerAllocation } from '@/entities/broker-position';
|
||||||
import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar';
|
import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar';
|
||||||
@ -14,26 +15,49 @@ function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string {
|
|||||||
return type === 'iis' ? 'ИИС' : 'Брокерский счёт';
|
return type === 'iis' ? 'ИИС' : 'Брокерский счёт';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cardSx = {
|
||||||
|
p: 2.75,
|
||||||
|
borderRadius: 3,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'rgba(21, 61, 43, 0.12)',
|
||||||
|
bgcolor: 'rgba(255, 255, 255, 0.92)',
|
||||||
|
boxShadow: '0 20px 45px rgba(31, 48, 39, 0.08)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statSx = {
|
||||||
|
display: 'grid',
|
||||||
|
gap: 1,
|
||||||
|
p: 1.75,
|
||||||
|
borderRadius: '18px',
|
||||||
|
background: 'linear-gradient(135deg, rgba(255,255,255,0.92), rgba(241,245,239,0.88))',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'rgba(31,48,39,0.08)',
|
||||||
|
};
|
||||||
|
|
||||||
function BrokerAccountCardSkeleton({ name, typeLabel }: { name: string; typeLabel: string }) {
|
function BrokerAccountCardSkeleton({ name, typeLabel }: { name: string; typeLabel: string }) {
|
||||||
return (
|
return (
|
||||||
<article className="broker-account-card broker-account-card--loading" aria-busy="true">
|
<Box aria-busy="true" sx={cardSx}>
|
||||||
<div className="broker-account-card__header">
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1.5, alignItems: 'start' }}>
|
||||||
<div>
|
<Box>
|
||||||
<p className="broker-account-card__eyebrow">{typeLabel}</p>
|
<Text variant="label" tone="secondary">
|
||||||
<h2 className="broker-account-card__title">{name}</h2>
|
{typeLabel}
|
||||||
</div>
|
</Text>
|
||||||
</div>
|
<Heading level={2}>{name}</Heading>
|
||||||
<div className="broker-account-card__grid">
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 1.5, mt: 2 }}
|
||||||
|
>
|
||||||
{[1, 2, 3, 4].map((item) => (
|
{[1, 2, 3, 4].map((item) => (
|
||||||
<div className="broker-account-card__stat" key={item}>
|
<Box key={item} sx={statSx}>
|
||||||
<SkeletonBlock height={12} width="50%" />
|
<Skeleton height={12} width="50%" shape="text" />
|
||||||
<SkeletonBlock height={24} width="75%" />
|
<Skeleton height={24} width="75%" shape="text" />
|
||||||
</div>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
<SkeletonBlock height={16} width="100%" borderRadius={999} />
|
<Skeleton height={16} width="100%" shape="rounded" />
|
||||||
<SkeletonBlock height={16} width="65%" />
|
<Skeleton height={16} width="65%" shape="rounded" />
|
||||||
</article>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,20 +69,26 @@ function BrokerAccountCardError({
|
|||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<article className="broker-account-card broker-account-card--error">
|
<Box sx={cardSx}>
|
||||||
<div className="broker-account-card__header">
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1.5, alignItems: 'start' }}>
|
||||||
<div>
|
<Box>
|
||||||
<p className="broker-account-card__eyebrow">{brokerAccountTypeLabel(account.type)}</p>
|
<Text variant="label" tone="secondary">
|
||||||
<h2 className="broker-account-card__title">{account.name}</h2>
|
{brokerAccountTypeLabel(account.type)}
|
||||||
</div>
|
</Text>
|
||||||
</div>
|
<Heading level={2}>{account.name}</Heading>
|
||||||
<div className="broker-account-card__alert" role="alert">
|
</Box>
|
||||||
<p>Не удалось загрузить данные счёта</p>
|
</Box>
|
||||||
<button className="broker-account-card__retry" type="button" onClick={onRetry}>
|
<Alert
|
||||||
Повторить
|
severity="error"
|
||||||
</button>
|
action={
|
||||||
</div>
|
<Button variant="primary" onClick={onRetry}>
|
||||||
</article>
|
Повторить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Не удалось загрузить данные счёта
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,44 +104,77 @@ function BrokerAccountCardSuccess({
|
|||||||
const allocation = buildBrokerAllocation(portfolio);
|
const allocation = buildBrokerAllocation(portfolio);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Box
|
||||||
className="broker-account-card broker-account-card--link"
|
component={Link}
|
||||||
to={`/broker/${encodeURIComponent(account.id)}`}
|
to={`/broker/${encodeURIComponent(account.id)}`}
|
||||||
|
sx={{
|
||||||
|
...cardSx,
|
||||||
|
display: 'grid',
|
||||||
|
gap: 2.25,
|
||||||
|
color: 'inherit',
|
||||||
|
textDecoration: 'none',
|
||||||
|
transition: 'transform 0.22s ease, box-shadow 0.22s ease, border-color 0.22s ease',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
boxShadow: '0 26px 50px rgba(31,48,39,0.11)',
|
||||||
|
borderColor: 'rgba(38,92,55,0.22)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="broker-account-card__header">
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1.5, alignItems: 'start' }}>
|
||||||
<div>
|
<Box>
|
||||||
<p className="broker-account-card__eyebrow">{typeLabel}</p>
|
<Text variant="label" tone="secondary">
|
||||||
<h2 className="broker-account-card__title">{account.name}</h2>
|
{typeLabel}
|
||||||
</div>
|
</Text>
|
||||||
{openedAt ? <p className="broker-account-card__opened">Открыт {openedAt}</p> : null}
|
<Heading level={2}>{account.name}</Heading>
|
||||||
</div>
|
</Box>
|
||||||
|
{openedAt ? (
|
||||||
|
<Text tone="secondary" variant="caption">
|
||||||
|
Открыт {openedAt}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<div className="broker-account-card__grid">
|
<Box
|
||||||
<div className="broker-account-card__stat broker-account-card__stat--wide">
|
sx={{
|
||||||
<span>Стоимость</span>
|
display: 'grid',
|
||||||
<strong>{formatBrokerMoney(portfolio.totals.portfolio)}</strong>
|
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
|
||||||
</div>
|
gap: 1.5,
|
||||||
<div className="broker-account-card__stat">
|
}}
|
||||||
<span>За день</span>
|
>
|
||||||
<strong>
|
<Box sx={{ ...statSx, gridColumn: 'span 2' }}>
|
||||||
|
<Text variant="label" tone="secondary">
|
||||||
|
Стоимость
|
||||||
|
</Text>
|
||||||
|
<Text>{formatBrokerMoney(portfolio.totals.portfolio)}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box sx={statSx}>
|
||||||
|
<Text variant="label" tone="secondary">
|
||||||
|
За день
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
{formatBrokerSignedCurrencyValue(
|
{formatBrokerSignedCurrencyValue(
|
||||||
portfolio.totals.portfolio?.currency ?? 'RUB',
|
portfolio.totals.portfolio?.currency ?? 'RUB',
|
||||||
portfolio.yields.daily?.value ?? null,
|
portfolio.yields.daily?.value ?? null,
|
||||||
)}
|
)}
|
||||||
</strong>
|
</Text>
|
||||||
</div>
|
</Box>
|
||||||
<div className="broker-account-card__stat">
|
<Box sx={statSx}>
|
||||||
<span>Дневная динамика</span>
|
<Text variant="label" tone="secondary">
|
||||||
<strong>{formatBrokerSignedPercent(portfolio.yields.dailyPercent)}</strong>
|
Дневная динамика
|
||||||
</div>
|
</Text>
|
||||||
<div className="broker-account-card__stat">
|
<Text>{formatBrokerSignedPercent(portfolio.yields.dailyPercent)}</Text>
|
||||||
<span>Ожидаемая доходность</span>
|
</Box>
|
||||||
<strong>{formatBrokerSignedPercent(portfolio.yields.expectedPercent)}</strong>
|
<Box sx={statSx}>
|
||||||
</div>
|
<Text variant="label" tone="secondary">
|
||||||
</div>
|
Ожидаемая доходность
|
||||||
|
</Text>
|
||||||
|
<Text>{formatBrokerSignedPercent(portfolio.yields.expectedPercent)}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<BrokerAllocationBar title={`Структура счёта ${account.name}`} items={allocation.sectors} />
|
<BrokerAllocationBar title={`Структура счёта ${account.name}`} items={allocation.sectors} />
|
||||||
</Link>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { NavLink, Outlet, useOutletContext, useParams } from 'react-router-dom';
|
import { NavLink, Outlet, useOutletContext, useParams } from 'react-router-dom';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { Heading } from '@moex-vibe/design-system';
|
||||||
import { useBrokerPortfolio } from '@/entities/broker-account';
|
import { useBrokerPortfolio } from '@/entities/broker-account';
|
||||||
|
|
||||||
export type BrokerAccountContext = {
|
export type BrokerAccountContext = {
|
||||||
@ -10,40 +12,87 @@ export function useBrokerAccountContext() {
|
|||||||
return useOutletContext<BrokerAccountContext>();
|
return useOutletContext<BrokerAccountContext>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ to: '', label: 'Обзор', end: true },
|
||||||
|
{ to: '/shares', label: 'Акции' },
|
||||||
|
{ to: '/bonds', label: 'Облигации' },
|
||||||
|
{ to: '/operations', label: 'Операции' },
|
||||||
|
];
|
||||||
|
|
||||||
export function BrokerAccountLayout() {
|
export function BrokerAccountLayout() {
|
||||||
const { accountId = '' } = useParams();
|
const { accountId = '' } = useParams();
|
||||||
const portfolio = useBrokerPortfolio(accountId);
|
const portfolio = useBrokerPortfolio(accountId);
|
||||||
const basePath = `/broker/${encodeURIComponent(accountId)}`;
|
const basePath = `/broker/${encodeURIComponent(accountId)}`;
|
||||||
const context: BrokerAccountContext = { accountId, portfolio };
|
const context: BrokerAccountContext = { accountId, portfolio };
|
||||||
const linkClassName = ({ isActive }: { isActive: boolean }) =>
|
|
||||||
`broker-account__link${isActive ? ' is-active' : ''}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="broker-account">
|
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||||
<header className="broker-account__header">
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||||
<h1>{portfolio.data?.account.name || 'Брокерский счёт'}</h1>
|
<Heading level={1}>{portfolio.data?.account.name || 'Брокерский счёт'}</Heading>
|
||||||
</header>
|
</Box>
|
||||||
|
|
||||||
<div className="broker-account__workspace">
|
<Box
|
||||||
<nav className="broker-account__navigation" aria-label="Разделы брокерского счёта">
|
sx={{
|
||||||
<NavLink className={linkClassName} end to={basePath}>
|
display: 'grid',
|
||||||
Обзор
|
gridTemplateColumns: '1fr',
|
||||||
</NavLink>
|
gap: 2,
|
||||||
<NavLink className={linkClassName} to={`${basePath}/shares`}>
|
'@media (min-width: 720px)': {
|
||||||
Акции
|
gridTemplateColumns: 'minmax(150px, 190px) minmax(0, 1fr)',
|
||||||
</NavLink>
|
gap: 3,
|
||||||
<NavLink className={linkClassName} to={`${basePath}/bonds`}>
|
},
|
||||||
Облигации
|
}}
|
||||||
</NavLink>
|
>
|
||||||
<NavLink className={linkClassName} to={`${basePath}/operations`}>
|
<Box
|
||||||
Операции
|
component="nav"
|
||||||
</NavLink>
|
aria-label="Разделы брокерского счёта"
|
||||||
</nav>
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 0.5,
|
||||||
|
'@media (max-width: 719px)': {
|
||||||
|
flexDirection: 'row',
|
||||||
|
overflowX: 'auto',
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{links.map((link) => (
|
||||||
|
<Box
|
||||||
|
key={link.to}
|
||||||
|
component={NavLink}
|
||||||
|
to={`${basePath}${link.to}`}
|
||||||
|
end={link.end}
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.25,
|
||||||
|
borderRadius: 2,
|
||||||
|
color: 'text.secondary',
|
||||||
|
textDecoration: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
'&[aria-current="page"]': {
|
||||||
|
color: 'primary.main',
|
||||||
|
bgcolor: 'primary.light',
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: '3px solid',
|
||||||
|
outlineColor: 'primary.main',
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<div className="broker-account__content">
|
<Box sx={{ minWidth: 0 }}>
|
||||||
<Outlet context={context} />
|
<Outlet context={context} />
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
|
import { Box } from '@mui/material';
|
||||||
|
import { Skeleton, Text } from '@moex-vibe/design-system';
|
||||||
import type { BrokerAccountsAggregate } from '@/entities/broker-account';
|
import type { BrokerAccountsAggregate } from '@/entities/broker-account';
|
||||||
import { buildBrokerAllocation } from '@/entities/broker-position';
|
import { buildBrokerAllocation } from '@/entities/broker-position';
|
||||||
import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar';
|
import { BrokerAllocationBar } from '@/shared/ui/broker-allocation-bar';
|
||||||
@ -21,42 +22,80 @@ export function BrokerAccountsSummary({
|
|||||||
}) {
|
}) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<section className="broker-accounts-summary broker-accounts-summary--loading">
|
<Box sx={{ display: 'grid', gap: 2 }}>
|
||||||
<div className="broker-accounts-summary__hero">
|
<Box
|
||||||
<SkeletonBlock height={18} width="34%" />
|
sx={{
|
||||||
<SkeletonBlock height={52} width="48%" />
|
px: 3.75,
|
||||||
<SkeletonBlock height={18} width="28%" />
|
py: 3.5,
|
||||||
</div>
|
borderRadius: 3,
|
||||||
<div className="broker-accounts-summary__metrics">
|
background: 'linear-gradient(135deg, rgba(19,54,38,0.97), rgba(26,71,47,0.95))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton height={18} width="34%" shape="text" />
|
||||||
|
<Skeleton height={52} width="48%" shape="text" />
|
||||||
|
<Skeleton height={18} width="28%" shape="text" />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 2 }}>
|
||||||
{[1, 2, 3].map((item) => (
|
{[1, 2, 3].map((item) => (
|
||||||
<div className="broker-accounts-summary__metric" key={item}>
|
<Box
|
||||||
<SkeletonBlock height={14} width="45%" />
|
key={item}
|
||||||
<SkeletonBlock height={26} width="70%" />
|
sx={{
|
||||||
</div>
|
display: 'grid',
|
||||||
|
gap: 1,
|
||||||
|
p: 1.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'rgba(31,48,39,0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton height={14} width="45%" shape="text" />
|
||||||
|
<Skeleton height={26} width="70%" shape="text" />
|
||||||
|
</Box>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
</section>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="broker-accounts-summary" aria-label="Общая сводка по счетам">
|
<Box component="section" sx={{ display: 'grid', gap: 2.5 }} aria-label="Общая сводка по счетам">
|
||||||
<div className="broker-accounts-summary__hero">
|
<Box
|
||||||
<div>
|
sx={{
|
||||||
<p className="broker-accounts-summary__eyebrow">Финансовый обзор</p>
|
px: 3.75,
|
||||||
<h2 className="broker-accounts-summary__title">Счета в одном кадре</h2>
|
py: 3.5,
|
||||||
</div>
|
borderRadius: 3,
|
||||||
<div className="broker-accounts-summary__status">
|
background: 'linear-gradient(135deg, rgba(19,54,38,0.97), rgba(26,71,47,0.95))',
|
||||||
<span>{totalCount} счетов</span>
|
}}
|
||||||
{availableCount !== totalCount ? (
|
>
|
||||||
<span>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, alignItems: 'start' }}>
|
||||||
Доступно по {availableCount} из {totalCount} счетов
|
<Box>
|
||||||
</span>
|
<Box sx={{ fontSize: 12, lineHeight: 1.33, color: 'rgba(255,255,255,0.55)' }}>
|
||||||
) : null}
|
Финансовый обзор
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
<Box sx={{ fontSize: 22, fontWeight: 700, lineHeight: 1.27, color: '#fff' }}>
|
||||||
|
Счета в одном кадре
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'grid', gap: 0.25, textAlign: 'right' }}>
|
||||||
|
<Box sx={{ fontSize: 13, lineHeight: 1.53, color: 'rgba(255,255,255,0.7)' }}>
|
||||||
|
{totalCount} счетов
|
||||||
|
</Box>
|
||||||
|
{availableCount !== totalCount ? (
|
||||||
|
<Box sx={{ fontSize: 12, lineHeight: 1.33, color: 'rgba(255,255,255,0.45)' }}>
|
||||||
|
Доступно по {availableCount} из {totalCount} счетов
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<div className="broker-accounts-summary__currency-grid">
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
|
||||||
|
gap: 2.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{aggregate.portfolios.map((portfolioSummary) => {
|
{aggregate.portfolios.map((portfolioSummary) => {
|
||||||
const allocation = buildBrokerAllocation({
|
const allocation = buildBrokerAllocation({
|
||||||
account: {
|
account: {
|
||||||
@ -114,51 +153,68 @@ export function BrokerAccountsSummary({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<Box
|
||||||
className="broker-accounts-summary__currency-card"
|
|
||||||
key={portfolioSummary.currency}
|
key={portfolioSummary.currency}
|
||||||
|
sx={{
|
||||||
|
p: 2.75,
|
||||||
|
borderRadius: 3,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'rgba(21, 61, 43, 0.12)',
|
||||||
|
bgcolor: 'rgba(255, 255, 255, 0.92)',
|
||||||
|
boxShadow: '0 20px 45px rgba(31, 48, 39, 0.08)',
|
||||||
|
display: 'grid',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="broker-accounts-summary__currency-header">
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<span className="broker-accounts-summary__currency">
|
<Box component="span" sx={{ fontSize: 16, fontWeight: 600, lineHeight: 1.25 }}>
|
||||||
{portfolioSummary.currency}
|
{portfolioSummary.currency}
|
||||||
</span>
|
</Box>
|
||||||
<strong className="broker-accounts-summary__total">
|
<Box component="span" sx={{ fontSize: 22, fontWeight: 700, lineHeight: 1.27 }}>
|
||||||
{formatBrokerCurrencyValue(portfolioSummary.currency, portfolioSummary.total)}
|
{formatBrokerCurrencyValue(portfolioSummary.currency, portfolioSummary.total)}
|
||||||
</strong>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
<dl className="broker-accounts-summary__metrics">
|
<Box
|
||||||
<div className="broker-accounts-summary__metric">
|
sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 1.5 }}
|
||||||
<dt>За день</dt>
|
>
|
||||||
<dd>
|
<Box sx={{ display: 'grid', gap: 0.25 }}>
|
||||||
|
<Text variant="label" tone="secondary">
|
||||||
|
За день
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
{formatBrokerSignedCurrencyValue(
|
{formatBrokerSignedCurrencyValue(
|
||||||
portfolioSummary.currency,
|
portfolioSummary.currency,
|
||||||
portfolioSummary.daily,
|
portfolioSummary.daily,
|
||||||
)}
|
)}
|
||||||
</dd>
|
</Text>
|
||||||
</div>
|
</Box>
|
||||||
<div className="broker-accounts-summary__metric">
|
<Box sx={{ display: 'grid', gap: 0.25 }}>
|
||||||
<dt>Динамика</dt>
|
<Text variant="label" tone="secondary">
|
||||||
<dd>{formatBrokerSignedPercent(portfolioSummary.dailyPercent)}</dd>
|
Динамика
|
||||||
</div>
|
</Text>
|
||||||
<div className="broker-accounts-summary__metric">
|
<Text>{formatBrokerSignedPercent(portfolioSummary.dailyPercent)}</Text>
|
||||||
<dt>Свободные деньги</dt>
|
</Box>
|
||||||
<dd>
|
<Box sx={{ display: 'grid', gap: 0.25 }}>
|
||||||
|
<Text variant="label" tone="secondary">
|
||||||
|
Свободные деньги
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
{formatBrokerCurrencyValue(
|
{formatBrokerCurrencyValue(
|
||||||
portfolioSummary.currency,
|
portfolioSummary.currency,
|
||||||
aggregate.cash.find((cash) => cash.currency === portfolioSummary.currency)
|
aggregate.cash.find((cash) => cash.currency === portfolioSummary.currency)
|
||||||
?.value ?? 0,
|
?.value ?? 0,
|
||||||
)}
|
)}
|
||||||
</dd>
|
</Text>
|
||||||
</div>
|
</Box>
|
||||||
</dl>
|
</Box>
|
||||||
<BrokerAllocationBar
|
<BrokerAllocationBar
|
||||||
title={`Распределение активов ${portfolioSummary.currency}`}
|
title={`Распределение активов ${portfolioSummary.currency}`}
|
||||||
items={allocation.sectors}
|
items={allocation.sectors}
|
||||||
/>
|
/>
|
||||||
</article>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</Box>
|
||||||
</section>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import type { BrokerPortfolio } from '@/shared/api/responses';
|
import type { BrokerPortfolio } from '@/shared/api/responses';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { Text } from '@moex-vibe/design-system';
|
||||||
import { buildBrokerAllocation } from '@/entities/broker-position';
|
import { buildBrokerAllocation } from '@/entities/broker-position';
|
||||||
import { formatBrokerCurrencyValue } from '@/shared/lib/formatters';
|
import { formatBrokerCurrencyValue } from '@/shared/lib/formatters';
|
||||||
|
|
||||||
@ -26,7 +28,7 @@ export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfoli
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<figure className="broker-allocation">
|
<Box component="figure" sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||||
<svg role="img" aria-label="Структура брокерского портфеля" viewBox="0 0 120 120">
|
<svg role="img" aria-label="Структура брокерского портфеля" viewBox="0 0 120 120">
|
||||||
<title>Структура брокерского портфеля</title>
|
<title>Структура брокерского портфеля</title>
|
||||||
{arcs.map((sector) => (
|
{arcs.map((sector) => (
|
||||||
@ -44,40 +46,50 @@ export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfoli
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
<figcaption>
|
<Box component="figcaption" sx={{ display: 'grid', gap: 1.5 }}>
|
||||||
{sectors.length === 0 ? (
|
{sectors.length === 0 ? (
|
||||||
<p>Нет данных для распределения</p>
|
<Text tone="muted">Нет данных для распределения</Text>
|
||||||
) : (
|
) : (
|
||||||
<ul>
|
<Box component="ul" sx={{ listStyle: 'none', display: 'grid', gap: 1 }}>
|
||||||
{sectors.map((sector) => (
|
{sectors.map((sector) => (
|
||||||
<li key={sector.key}>
|
<Box
|
||||||
<span
|
component="li"
|
||||||
className="broker-allocation__swatch"
|
key={sector.key}
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 2,
|
||||||
|
height: 2,
|
||||||
|
borderRadius: 0.25,
|
||||||
|
background: sector.color,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{ background: sector.color }}
|
|
||||||
/>
|
/>
|
||||||
<span>
|
<Box component="span">
|
||||||
{sector.label}: {formatBrokerCurrencyValue(currency, sector.value)} ·{' '}
|
{sector.label}: {formatBrokerCurrencyValue(currency, sector.value)} ·{' '}
|
||||||
{sector.percent.toFixed(1)}%
|
{sector.percent.toFixed(1)}%
|
||||||
</span>
|
</Box>
|
||||||
</li>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{negative.length > 0 && (
|
{negative.length > 0 && (
|
||||||
<ul
|
<Box
|
||||||
className="broker-allocation__negative"
|
component="ul"
|
||||||
aria-label="Отрицательные значения распределения"
|
aria-label="Отрицательные значения распределения"
|
||||||
|
sx={{ listStyle: 'none', display: 'grid', gap: 1 }}
|
||||||
>
|
>
|
||||||
{negative.map((item) => (
|
{negative.map((item) => (
|
||||||
<li key={item.key}>
|
<Box component="li" key={item.key}>
|
||||||
{item.label}: отрицательное значение{' '}
|
{item.label}: отрицательное значение{' '}
|
||||||
{formatBrokerCurrencyValue(currency, item.value)}
|
{formatBrokerCurrencyValue(currency, item.value)}
|
||||||
</li>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</figcaption>
|
</Box>
|
||||||
</figure>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { BrokerOperation, BrokerOperationsPage } from '@/shared/api/responses';
|
import type { BrokerOperation, BrokerOperationsPage } from '@/shared/api/responses';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { Button, Heading, Skeleton, Text } from '@moex-vibe/design-system';
|
||||||
import { TableSkeleton } from '@/shared/ui/TableSkeleton';
|
import { TableSkeleton } from '@/shared/ui/TableSkeleton';
|
||||||
import {
|
import {
|
||||||
getBrokerOperationImpact,
|
getBrokerOperationImpact,
|
||||||
@ -10,42 +12,33 @@ import {
|
|||||||
import { getBrokerInstrumentPath } from '@/entities/broker-position';
|
import { getBrokerInstrumentPath } from '@/entities/broker-position';
|
||||||
import { formatBrokerSignedMoney } from '@/shared/lib/formatters';
|
import { formatBrokerSignedMoney } from '@/shared/lib/formatters';
|
||||||
|
|
||||||
const tableStyle = {
|
const tableSx = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
borderCollapse: 'collapse',
|
borderCollapse: 'collapse',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
} satisfies React.CSSProperties;
|
} as const;
|
||||||
|
|
||||||
const thStyle = {
|
const thSx = {
|
||||||
borderBottom: '1px solid #e0e0e0',
|
borderBottom: '1px solid',
|
||||||
color: 'var(--color-text-secondary)',
|
borderColor: 'divider',
|
||||||
|
color: 'text.secondary',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
padding: '10px 8px',
|
p: 1,
|
||||||
} satisfies React.CSSProperties;
|
textAlign: 'left',
|
||||||
|
} as const;
|
||||||
|
|
||||||
const tdStyle = {
|
const tdSx = {
|
||||||
borderBottom: '1px solid #eeeeee',
|
borderBottom: '1px solid',
|
||||||
padding: '10px 8px',
|
borderColor: 'divider',
|
||||||
|
p: 1,
|
||||||
verticalAlign: 'top',
|
verticalAlign: 'top',
|
||||||
} satisfies React.CSSProperties;
|
textAlign: 'left',
|
||||||
|
} as const;
|
||||||
|
|
||||||
const pagButtonStyle = {
|
const tdSxRight = {
|
||||||
padding: '6px 14px',
|
...tdSx,
|
||||||
borderRadius: 6,
|
textAlign: 'right',
|
||||||
border: '1px solid #e0e0e0',
|
} as const;
|
||||||
background: 'var(--color-surface)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: 'pointer',
|
|
||||||
lineHeight: 1.4,
|
|
||||||
} satisfies React.CSSProperties;
|
|
||||||
|
|
||||||
const pagButtonDisabledStyle = {
|
|
||||||
...pagButtonStyle,
|
|
||||||
opacity: 0.35,
|
|
||||||
cursor: 'not-allowed',
|
|
||||||
} satisfies React.CSSProperties;
|
|
||||||
|
|
||||||
function formatDate(value: string | null) {
|
function formatDate(value: string | null) {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
@ -53,11 +46,10 @@ function formatDate(value: string | null) {
|
|||||||
return new Date(value).toLocaleString('ru-RU');
|
return new Date(value).toLocaleString('ru-RU');
|
||||||
}
|
}
|
||||||
|
|
||||||
function moneyColor(impact: BrokerOperationImpact): string {
|
function operationTone(impact: BrokerOperationImpact) {
|
||||||
if (impact === 'adds') return 'var(--color-positive)';
|
if (impact === 'adds') return 'positive' as const;
|
||||||
if (impact === 'reduces') return 'var(--color-negative)';
|
if (impact === 'reduces') return 'negative' as const;
|
||||||
|
return 'primary' as const;
|
||||||
return 'var(--color-text)';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function OperationInstrument({ operation }: { operation: BrokerOperation }) {
|
function OperationInstrument({ operation }: { operation: BrokerOperation }) {
|
||||||
@ -69,19 +61,23 @@ function OperationInstrument({ operation }: { operation: BrokerOperation }) {
|
|||||||
});
|
});
|
||||||
const name = operation.name || operation.description;
|
const name = operation.name || operation.description;
|
||||||
|
|
||||||
if (!path && !name) return <span>-</span>;
|
if (!path && !name) return <Text>-</Text>;
|
||||||
if (!path) return <span>{name}</span>;
|
if (!path) return <Text>{name}</Text>;
|
||||||
if (!ticker || ticker === '-') return <Link to={path}>{name}</Link>;
|
if (!ticker || ticker === '-') return <Link to={path}>{name}</Link>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gap: 2 }}>
|
<Box sx={{ display: 'grid', gap: 0.5 }}>
|
||||||
<Link to={path} style={{ fontWeight: 700 }}>
|
<Link to={path}>
|
||||||
{ticker}
|
<Box component="span" sx={{ fontWeight: 700 }}>
|
||||||
|
{ticker}
|
||||||
|
</Box>
|
||||||
</Link>
|
</Link>
|
||||||
{name && name !== ticker && (
|
{name && name !== ticker && (
|
||||||
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>{name}</span>
|
<Text variant="caption" tone="secondary">
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,146 +99,156 @@ export function BrokerOperationsTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-busy={isFetching}>
|
<section aria-busy={isFetching}>
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 12,
|
gap: 1.5,
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginBottom: 12,
|
mb: 1.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 style={{ fontSize: 20, margin: 0 }}>{title}</h2>
|
<Heading level={2}>{title}</Heading>
|
||||||
{headerAction}
|
{headerAction}
|
||||||
{pagination && (
|
{pagination && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
aria-label="Предыдущая страница"
|
aria-label="Предыдущая страница"
|
||||||
onClick={onPrevious}
|
onClick={onPrevious}
|
||||||
disabled={!canGoBack || isFetching}
|
disabled={!canGoBack || isFetching}
|
||||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
|
||||||
>
|
>
|
||||||
{isFetching ? (
|
{isFetching ? <Skeleton shape="circular" width={14} height={14} /> : '←'}
|
||||||
<span
|
</Button>
|
||||||
className="loading-spinner"
|
<Box
|
||||||
style={{ width: 14, height: 14, display: 'block' }}
|
sx={{
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
'←'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
minWidth: 20,
|
minWidth: 20,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: 'var(--color-text-secondary)',
|
color: 'text.secondary',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{pageNumber}
|
{pageNumber}
|
||||||
</span>
|
</Box>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
aria-label="Следующая страница"
|
aria-label="Следующая страница"
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={!canGoForward || isFetching}
|
disabled={!canGoForward || isFetching}
|
||||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
|
||||||
>
|
>
|
||||||
{isFetching ? (
|
{isFetching ? <Skeleton shape="circular" width={14} height={14} /> : '→'}
|
||||||
<span
|
</Button>
|
||||||
className="loading-spinner"
|
</Box>
|
||||||
style={{ width: 14, height: 14, display: 'block' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
'→'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
<Box sx={{ overflowX: 'auto', bgcolor: 'surface.default' }}>
|
||||||
<table style={tableStyle}>
|
<Box component="table" sx={tableSx}>
|
||||||
<thead>
|
<Box component="thead">
|
||||||
<tr>
|
<Box component="tr">
|
||||||
<th align="left" style={thStyle}>
|
<Box component="th" sx={thSx}>
|
||||||
Дата
|
Дата
|
||||||
</th>
|
</Box>
|
||||||
<th align="left" style={thStyle}>
|
<Box component="th" sx={thSx}>
|
||||||
Тип
|
Тип
|
||||||
</th>
|
</Box>
|
||||||
<th align="left" style={thStyle}>
|
<Box component="th" sx={thSx}>
|
||||||
Инструмент
|
Инструмент
|
||||||
</th>
|
</Box>
|
||||||
<th align="right" style={thStyle}>
|
<Box component="th" sx={{ ...thSx, textAlign: 'right' }}>
|
||||||
Сумма
|
Сумма
|
||||||
</th>
|
</Box>
|
||||||
</tr>
|
</Box>
|
||||||
</thead>
|
</Box>
|
||||||
<TableSkeleton
|
<TableSkeleton
|
||||||
rows={5}
|
rows={5}
|
||||||
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
|
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
|
||||||
/>
|
/>
|
||||||
</table>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
) : operations.length === 0 && !isFetching ? (
|
) : operations.length === 0 && !isFetching ? (
|
||||||
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
|
<Text component="p" tone="muted">
|
||||||
|
{emptyMessage}
|
||||||
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-container">
|
<Box sx={{ position: 'relative' }}>
|
||||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
<Box sx={{ overflowX: 'auto', bgcolor: 'surface.default' }}>
|
||||||
<table style={tableStyle}>
|
<Box component="table" sx={tableSx}>
|
||||||
<thead>
|
<Box component="thead">
|
||||||
<tr>
|
<Box component="tr">
|
||||||
<th align="left" style={thStyle}>
|
<Box component="th" sx={thSx}>
|
||||||
Дата
|
Дата
|
||||||
</th>
|
</Box>
|
||||||
<th align="left" style={thStyle}>
|
<Box component="th" sx={thSx}>
|
||||||
Тип
|
Тип
|
||||||
</th>
|
</Box>
|
||||||
<th align="left" style={thStyle}>
|
<Box component="th" sx={thSx}>
|
||||||
Инструмент
|
Инструмент
|
||||||
</th>
|
</Box>
|
||||||
<th align="right" style={thStyle}>
|
<Box component="th" sx={{ ...thSx, textAlign: 'right' }}>
|
||||||
Сумма
|
Сумма
|
||||||
</th>
|
</Box>
|
||||||
</tr>
|
</Box>
|
||||||
</thead>
|
</Box>
|
||||||
<tbody>
|
<Box component="tbody">
|
||||||
{operations.map((operation) => {
|
{operations.map((operation) => {
|
||||||
const impact = getBrokerOperationImpact(operation);
|
const impact = getBrokerOperationImpact(operation);
|
||||||
return (
|
return (
|
||||||
<tr key={operation.cursor || operation.id}>
|
<Box component="tr" key={operation.cursor || operation.id}>
|
||||||
<td style={tdStyle}>{formatDate(operation.date)}</td>
|
<Box component="td" sx={tdSx}>
|
||||||
<td style={tdStyle}>
|
{formatDate(operation.date)}
|
||||||
<span>{getBrokerOperationTypeLabel(operation)}</span>
|
</Box>
|
||||||
</td>
|
<Box component="td" sx={tdSx}>
|
||||||
<td style={tdStyle}>
|
<Text>{getBrokerOperationTypeLabel(operation)}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box component="td" sx={tdSx}>
|
||||||
<OperationInstrument operation={operation} />
|
<OperationInstrument operation={operation} />
|
||||||
</td>
|
</Box>
|
||||||
<td
|
<Box
|
||||||
|
component="td"
|
||||||
|
sx={{
|
||||||
|
...tdSxRight,
|
||||||
|
color:
|
||||||
|
operationTone(impact) === 'positive'
|
||||||
|
? 'success.main'
|
||||||
|
: operationTone(impact) === 'negative'
|
||||||
|
? 'error.main'
|
||||||
|
: 'text.primary',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
align="right"
|
align="right"
|
||||||
style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}
|
|
||||||
>
|
>
|
||||||
{formatBrokerSignedMoney(operation.payment)}
|
{formatBrokerSignedMoney(operation.payment)}
|
||||||
</td>
|
</Box>
|
||||||
</tr>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</Box>
|
||||||
</table>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
{isFetching && (
|
{isFetching && (
|
||||||
<div className="table-loading-overlay" role="status">
|
<Box
|
||||||
<div className="loading-spinner" aria-hidden="true" />
|
sx={{
|
||||||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 1,
|
||||||
|
bgcolor: 'rgba(255,255,255,0.7)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box className="loading-spinner" />
|
||||||
|
<Text variant="caption" tone="secondary">
|
||||||
{pagination ? `Загрузка страницы ${pageNumber}…` : 'Обновление операций…'}
|
{pagination ? `Загрузка страницы ${pageNumber}…` : 'Обновление операций…'}
|
||||||
</span>
|
</Text>
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { Text } from '@moex-vibe/design-system';
|
||||||
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
|
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
|
||||||
import { formatBrokerMoney, pluralize } from '@/shared/lib/formatters';
|
import { formatBrokerMoney, pluralize } from '@/shared/lib/formatters';
|
||||||
|
|
||||||
@ -11,6 +13,16 @@ function formatAllocationPercent(value: number | null) {
|
|||||||
return value === null ? '\u2014' : `${value.toFixed(1)}%`;
|
return value === null ? '\u2014' : `${value.toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cardSx = {
|
||||||
|
p: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: 'surface.default',
|
||||||
|
display: 'grid',
|
||||||
|
gap: 1,
|
||||||
|
};
|
||||||
|
|
||||||
export function BrokerAssetCards({
|
export function BrokerAssetCards({
|
||||||
accountId,
|
accountId,
|
||||||
portfolio,
|
portfolio,
|
||||||
@ -37,23 +49,23 @@ export function BrokerAssetCards({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="broker-overview__assets" aria-label="Основные классы активов">
|
<Box component="section" sx={{ display: 'grid', gap: 2 }} aria-label="Основные классы активов">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<Link
|
<Link key={card.label} to={card.path}>
|
||||||
className="broker-overview__card broker-overview__asset-link"
|
<Box sx={{ ...cardSx, color: 'inherit', textDecoration: 'none' }}>
|
||||||
key={card.label}
|
<Box component="span" sx={{ color: 'primary.main', fontSize: 18, fontWeight: 700 }}>
|
||||||
to={card.path}
|
{card.label}
|
||||||
>
|
</Box>
|
||||||
<strong className="broker-overview__asset-title">{card.label}</strong>
|
<Text>
|
||||||
<span>
|
{card.count} {card.countLabel}
|
||||||
{card.count} {card.countLabel}
|
</Text>
|
||||||
</span>
|
<Text>{formatBrokerMoney(card.value)}</Text>
|
||||||
<span>{formatBrokerMoney(card.value)}</span>
|
<Text>
|
||||||
<span>
|
{formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))}
|
||||||
{formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))}
|
</Text>
|
||||||
</span>
|
</Box>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</section>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +1,55 @@
|
|||||||
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
|
import { Box } from '@mui/material';
|
||||||
|
import { Skeleton } from '@moex-vibe/design-system';
|
||||||
|
|
||||||
export function BrokerOverviewSkeleton() {
|
export function BrokerOverviewSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="broker-overview" aria-label="Загрузка сводки счёта">
|
<Box sx={{ display: 'grid', gap: 3 }} aria-label="Загрузка сводки счёта">
|
||||||
<div className="broker-overview__summary">
|
<Box sx={{ display: 'grid', gap: 2 }}>
|
||||||
{[1, 2].map((item) => (
|
{[1, 2].map((item) => (
|
||||||
<div className="broker-overview__card" key={item}>
|
<Box
|
||||||
<SkeletonBlock height={16} width="45%" />
|
key={item}
|
||||||
<SkeletonBlock height={28} width="70%" />
|
sx={{
|
||||||
<SkeletonBlock height={16} width="55%" />
|
p: 2,
|
||||||
</div>
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: 'surface.default',
|
||||||
|
display: 'grid',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton height={16} width="45%" shape="text" />
|
||||||
|
<Skeleton height={28} width="70%" shape="text" />
|
||||||
|
<Skeleton height={16} width="55%" shape="text" />
|
||||||
|
</Box>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
<div className="broker-allocation">
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||||
<SkeletonBlock height={160} width={160} borderRadius={80} />
|
<Skeleton height={160} width={160} shape="circular" />
|
||||||
<SkeletonBlock height={80} width="60%" />
|
<Box sx={{ display: 'grid', gap: 1, flex: 1 }}>
|
||||||
</div>
|
<Skeleton height={80} width="60%" shape="text" />
|
||||||
<div className="broker-overview__assets">
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'grid', gap: 2 }}>
|
||||||
{[1, 2].map((item) => (
|
{[1, 2].map((item) => (
|
||||||
<div className="broker-overview__card" key={item}>
|
<Box
|
||||||
<SkeletonBlock height={20} width="35%" />
|
key={item}
|
||||||
<SkeletonBlock height={16} width="55%" />
|
sx={{
|
||||||
<SkeletonBlock height={16} width="70%" />
|
p: 2,
|
||||||
</div>
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: 'surface.default',
|
||||||
|
display: 'grid',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton height={20} width="35%" shape="text" />
|
||||||
|
<Skeleton height={16} width="55%" shape="text" />
|
||||||
|
<Skeleton height={16} width="70%" shape="text" />
|
||||||
|
</Box>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { Text } from '@moex-vibe/design-system';
|
||||||
import type { BrokerPortfolio } from '@/shared/api/responses';
|
import type { BrokerPortfolio } from '@/shared/api/responses';
|
||||||
import {
|
import {
|
||||||
formatBrokerMoney as formatMoney,
|
formatBrokerMoney as formatMoney,
|
||||||
@ -6,31 +8,61 @@ import {
|
|||||||
|
|
||||||
export function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) {
|
export function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) {
|
||||||
return (
|
return (
|
||||||
<section className="broker-overview__summary" aria-label="Сводка счёта">
|
<Box component="section" sx={{ display: 'grid', gap: 2 }} aria-label="Сводка счёта">
|
||||||
<div className="broker-overview__card">
|
<Box
|
||||||
<span className="broker-overview__label">Стоимость портфеля</span>
|
sx={{
|
||||||
<strong className="broker-overview__total">
|
p: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: 'surface.default',
|
||||||
|
display: 'grid',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="label" tone="secondary">
|
||||||
|
Стоимость портфеля
|
||||||
|
</Text>
|
||||||
|
<Box component="span" sx={{ fontWeight: 700, fontSize: 24 }}>
|
||||||
{formatMoney(portfolio.totals.portfolio)}
|
{formatMoney(portfolio.totals.portfolio)}
|
||||||
</strong>
|
</Box>
|
||||||
<span>За день: {formatMoney(portfolio.yields.daily)}</span>
|
<Text>За день: {formatMoney(portfolio.yields.daily)}</Text>
|
||||||
<span>Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)}</span>
|
<Text>Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)}</Text>
|
||||||
<span>Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)}</span>
|
<Text>Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)}</Text>
|
||||||
</div>
|
</Box>
|
||||||
<div className="broker-overview__card">
|
<Box
|
||||||
<span className="broker-overview__label">Денежный остаток</span>
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: 'surface.default',
|
||||||
|
display: 'grid',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="label" tone="secondary">
|
||||||
|
Денежный остаток
|
||||||
|
</Text>
|
||||||
{portfolio.cash.length === 0 ? (
|
{portfolio.cash.length === 0 ? (
|
||||||
<span>Нет денежных остатков</span>
|
<Text>Нет денежных остатков</Text>
|
||||||
) : (
|
) : (
|
||||||
<ul className="broker-overview__cash">
|
<Box component="ul" sx={{ listStyle: 'none', display: 'grid', gap: 1 }}>
|
||||||
{portfolio.cash.map((money, index) => (
|
{portfolio.cash.map((money, index) => (
|
||||||
<li key={`${money.currency}-${index}`}>
|
<Box
|
||||||
<span>{money.currency}</span>
|
component="li"
|
||||||
<strong>{formatMoney(money)}</strong>
|
key={`${money.currency}-${index}`}
|
||||||
</li>
|
sx={{ display: 'flex', justifyContent: 'space-between', gap: 1.5 }}
|
||||||
|
>
|
||||||
|
<Text>{money.currency}</Text>
|
||||||
|
<Box component="span" sx={{ fontWeight: 700 }}>
|
||||||
|
{formatMoney(money)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
</section>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,44 +1,36 @@
|
|||||||
import type { BrokerPositionsPage as BrokerPositionsPageData } from '@/shared/api/responses';
|
import type { BrokerPositionsPage as BrokerPositionsPageData } from '@/shared/api/responses';
|
||||||
import { TableSkeleton } from '@/shared/ui/TableSkeleton';
|
import { Box } from '@mui/material';
|
||||||
|
import { Button, Heading, Skeleton, Text } from '@moex-vibe/design-system';
|
||||||
import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters';
|
import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters';
|
||||||
import { PositionTicker } from './PositionTicker';
|
import { PositionTicker } from './PositionTicker';
|
||||||
|
|
||||||
const tableStyle = {
|
const tableSx = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
borderCollapse: 'collapse',
|
borderCollapse: 'collapse',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
} satisfies React.CSSProperties;
|
} as const;
|
||||||
|
|
||||||
const thStyle = {
|
const thSx = {
|
||||||
borderBottom: '1px solid #e0e0e0',
|
borderBottom: '1px solid',
|
||||||
color: 'var(--color-text-secondary)',
|
borderColor: 'divider',
|
||||||
|
color: 'text.secondary',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
padding: '10px 8px',
|
p: 1,
|
||||||
} satisfies React.CSSProperties;
|
textAlign: 'left',
|
||||||
|
} as const;
|
||||||
|
|
||||||
const tdStyle = {
|
const tdSx = {
|
||||||
borderBottom: '1px solid #eeeeee',
|
borderBottom: '1px solid',
|
||||||
padding: '10px 8px',
|
borderColor: 'divider',
|
||||||
|
p: 1,
|
||||||
verticalAlign: 'top',
|
verticalAlign: 'top',
|
||||||
} satisfies React.CSSProperties;
|
textAlign: 'right',
|
||||||
|
} as const;
|
||||||
|
|
||||||
const pagButtonStyle = {
|
const tdSxLeft = {
|
||||||
padding: '6px 14px',
|
...tdSx,
|
||||||
borderRadius: 6,
|
textAlign: 'left',
|
||||||
border: '1px solid #e0e0e0',
|
} as const;
|
||||||
background: 'var(--color-surface)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: 'pointer',
|
|
||||||
lineHeight: 1.4,
|
|
||||||
} satisfies React.CSSProperties;
|
|
||||||
|
|
||||||
const pagButtonDisabledStyle = {
|
|
||||||
...pagButtonStyle,
|
|
||||||
opacity: 0.35,
|
|
||||||
cursor: 'not-allowed',
|
|
||||||
} satisfies React.CSSProperties;
|
|
||||||
|
|
||||||
function formatQuantity(value: number | null | undefined) {
|
function formatQuantity(value: number | null | undefined) {
|
||||||
return value == null ? '-' : value.toLocaleString('ru-RU');
|
return value == null ? '-' : value.toLocaleString('ru-RU');
|
||||||
@ -69,127 +61,127 @@ export function BrokerPositionTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby={`broker-${title.toLowerCase()}-heading`}>
|
<section aria-labelledby={`broker-${title.toLowerCase()}-heading`}>
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 12,
|
gap: 1.5,
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginBottom: 10,
|
mb: 1.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 id={`broker-${title.toLowerCase()}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
<Heading level={2} id={`broker-${title.toLowerCase()}-heading`}>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</Heading>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
aria-label="Предыдущая страница"
|
aria-label="Предыдущая страница"
|
||||||
onClick={onPrevious}
|
onClick={onPrevious}
|
||||||
disabled={!canGoBack || isFetching}
|
disabled={!canGoBack || isFetching}
|
||||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
|
||||||
>
|
>
|
||||||
{isFetching ? (
|
{isFetching ? <Skeleton shape="circular" width={14} height={14} /> : '←'}
|
||||||
<span
|
</Button>
|
||||||
className="loading-spinner"
|
<Box
|
||||||
style={{ width: 14, height: 14, display: 'block' }}
|
sx={{
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
'←'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
minWidth: 20,
|
minWidth: 20,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: 'var(--color-text-secondary)',
|
color: 'text.secondary',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{pageNumber}
|
{pageNumber}
|
||||||
</span>
|
</Box>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
aria-label="Следующая страница"
|
aria-label="Следующая страница"
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={!canGoForward || isFetching}
|
disabled={!canGoForward || isFetching}
|
||||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
|
||||||
>
|
>
|
||||||
{isFetching ? (
|
{isFetching ? <Skeleton shape="circular" width={14} height={14} /> : '→'}
|
||||||
<span
|
</Button>
|
||||||
className="loading-spinner"
|
</Box>
|
||||||
style={{ width: 14, height: 14, display: 'block' }}
|
</Box>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
'→'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
<Box sx={{ overflowX: 'auto', bgcolor: 'surface.default' }}>
|
||||||
<table style={tableStyle}>
|
<Box component="table" sx={tableSx}>
|
||||||
<thead>
|
<Box component="thead">
|
||||||
<tr>
|
<Box component="tr">
|
||||||
<th align="left" style={thStyle}>
|
<Box component="th" sx={thSx} align="left">
|
||||||
Тикер
|
Тикер
|
||||||
</th>
|
</Box>
|
||||||
<th align="left" style={thStyle}>
|
<Box component="th" sx={thSx} align="left">
|
||||||
Название
|
Название
|
||||||
</th>
|
</Box>
|
||||||
<th align="right" style={thStyle}>
|
<Box component="th" sx={thSx} align="right">
|
||||||
Количество
|
Количество
|
||||||
</th>
|
</Box>
|
||||||
<th align="right" style={thStyle}>
|
<Box component="th" sx={thSx} align="right">
|
||||||
Цена
|
Цена
|
||||||
</th>
|
</Box>
|
||||||
<th align="right" style={thStyle}>
|
<Box component="th" sx={thSx} align="right">
|
||||||
Стоимость
|
Стоимость
|
||||||
</th>
|
</Box>
|
||||||
</tr>
|
</Box>
|
||||||
</thead>
|
</Box>
|
||||||
<TableSkeleton
|
<Box component="tbody">
|
||||||
rows={4}
|
{[1, 2, 3, 4].map((row) => (
|
||||||
columns={[
|
<Box component="tr" key={row}>
|
||||||
{ width: '30%' },
|
<Box component="td" sx={tdSxLeft}>
|
||||||
{ width: '50%' },
|
<Skeleton height={12} width="30%" shape="text" />
|
||||||
{ width: '20%' },
|
</Box>
|
||||||
{ width: '25%' },
|
<Box component="td" sx={tdSxLeft}>
|
||||||
{ width: '25%' },
|
<Skeleton height={12} width="50%" shape="text" />
|
||||||
]}
|
</Box>
|
||||||
/>
|
<Box component="td" sx={tdSx}>
|
||||||
</table>
|
<Skeleton height={12} width="20%" shape="text" />
|
||||||
</div>
|
</Box>
|
||||||
|
<Box component="td" sx={tdSx}>
|
||||||
|
<Skeleton height={12} width="25%" shape="text" />
|
||||||
|
</Box>
|
||||||
|
<Box component="td" sx={tdSx}>
|
||||||
|
<Skeleton height={12} width="25%" shape="text" />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
) : positions.length === 0 && !isFetching ? (
|
) : positions.length === 0 && !isFetching ? (
|
||||||
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
|
<Text component="p" tone="secondary">
|
||||||
|
{emptyMessage}
|
||||||
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-container">
|
<Box sx={{ position: 'relative' }}>
|
||||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
<Box sx={{ overflowX: 'auto', bgcolor: 'surface.default' }}>
|
||||||
<table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}>
|
<Box component="table" sx={tableSx} aria-label={`Брокерские позиции: ${title}`}>
|
||||||
<thead>
|
<Box component="thead">
|
||||||
<tr>
|
<Box component="tr">
|
||||||
<th align="left" style={thStyle}>
|
<Box component="th" sx={thSx} align="left">
|
||||||
Тикер
|
Тикер
|
||||||
</th>
|
</Box>
|
||||||
<th align="left" style={thStyle}>
|
<Box component="th" sx={thSx} align="left">
|
||||||
Название
|
Название
|
||||||
</th>
|
</Box>
|
||||||
<th align="right" style={thStyle}>
|
<Box component="th" sx={thSx} align="right">
|
||||||
Количество
|
Количество
|
||||||
</th>
|
</Box>
|
||||||
<th align="right" style={thStyle}>
|
<Box component="th" sx={thSx} align="right">
|
||||||
Цена
|
Цена
|
||||||
</th>
|
</Box>
|
||||||
<th align="right" style={thStyle}>
|
<Box component="th" sx={thSx} align="right">
|
||||||
Стоимость
|
Стоимость
|
||||||
</th>
|
</Box>
|
||||||
</tr>
|
</Box>
|
||||||
</thead>
|
</Box>
|
||||||
<tbody>
|
<Box component="tbody">
|
||||||
{positions.map((position) => (
|
{positions.map((position) => (
|
||||||
<tr
|
<Box
|
||||||
|
component="tr"
|
||||||
key={
|
key={
|
||||||
position.positionUid ||
|
position.positionUid ||
|
||||||
position.instrumentUid ||
|
position.instrumentUid ||
|
||||||
@ -197,37 +189,45 @@ export function BrokerPositionTable({
|
|||||||
position.figi
|
position.figi
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<td style={tdStyle}>
|
<Box component="td" sx={tdSxLeft}>
|
||||||
<PositionTicker position={position} />
|
<PositionTicker position={position} />
|
||||||
</td>
|
</Box>
|
||||||
<td style={tdStyle}>
|
<Box component="td" sx={tdSxLeft}>
|
||||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
<Text tone="secondary">{position.name || '-'}</Text>
|
||||||
{position.name || '-'}
|
</Box>
|
||||||
</span>
|
<Box component="td" sx={tdSx}>
|
||||||
</td>
|
|
||||||
<td align="right" style={tdStyle}>
|
|
||||||
{formatQuantity(position.quantity)}
|
{formatQuantity(position.quantity)}
|
||||||
</td>
|
</Box>
|
||||||
<td align="right" style={tdStyle}>
|
<Box component="td" sx={tdSx}>
|
||||||
{formatMoney(position.currentPrice)}
|
{formatMoney(position.currentPrice)}
|
||||||
</td>
|
</Box>
|
||||||
<td align="right" style={tdStyle}>
|
<Box component="td" sx={tdSx}>
|
||||||
{formatMoney(position.currentValue)}
|
{formatMoney(position.currentValue)}
|
||||||
</td>
|
</Box>
|
||||||
</tr>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</Box>
|
||||||
</table>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
{isFetching && (
|
{isFetching && (
|
||||||
<div className="table-loading-overlay">
|
<Box
|
||||||
<div className="loading-spinner" />
|
sx={{
|
||||||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 1,
|
||||||
|
bgcolor: 'rgba(255,255,255,0.7)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box className="loading-spinner" />
|
||||||
|
<Text variant="caption" tone="secondary">
|
||||||
Загрузка страницы {pageNumber}…
|
Загрузка страницы {pageNumber}…
|
||||||
</span>
|
</Text>
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
import type { BrokerPosition } from '@/shared/api/responses';
|
import type { BrokerPosition } from '@/shared/api/responses';
|
||||||
import { getBrokerInstrumentPath } from '@/entities/broker-position';
|
import { getBrokerInstrumentPath } from '@/entities/broker-position';
|
||||||
|
|
||||||
@ -11,12 +12,18 @@ export function PositionTicker({ position }: { position: BrokerPosition }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!path || label === '-') {
|
if (!path || label === '-') {
|
||||||
return <strong>{label}</strong>;
|
return (
|
||||||
|
<Box component="span" sx={{ fontWeight: 700 }}>
|
||||||
|
{label}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={path} style={{ fontWeight: 700 }}>
|
<Link to={path}>
|
||||||
{label}
|
<Box component="span" sx={{ fontWeight: 700 }}>
|
||||||
|
{label}
|
||||||
|
</Box>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { Chip, Surface, Text, TextField } from '@moex-vibe/design-system';
|
||||||
import { useSearch } from '@/entities/search';
|
import { useSearch } from '@/entities/search';
|
||||||
|
|
||||||
export function SearchBar() {
|
export function SearchBar() {
|
||||||
@ -32,82 +34,81 @@ export function SearchBar() {
|
|||||||
const showResults = open && debounced.length >= 2;
|
const showResults = open && debounced.length >= 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={{ position: 'relative', width: 400, maxWidth: '100%' }}>
|
<Box ref={ref} sx={{ position: 'relative', width: 400, maxWidth: '100%' }}>
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
label="Поиск"
|
||||||
placeholder="Поиск акций и облигаций..."
|
placeholder="Поиск акций и облигаций..."
|
||||||
|
hiddenLabel
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value);
|
setQuery(e.target.value);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}}
|
}}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => setOpen(true)}
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid #ccc',
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: 14,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{showResults && (
|
{showResults && (
|
||||||
<ul
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '100%',
|
top: '100%',
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
background: '#fff',
|
mt: 0.5,
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: 6,
|
|
||||||
marginTop: 4,
|
|
||||||
padding: 0,
|
|
||||||
listStyle: 'none',
|
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
maxHeight: 360,
|
maxHeight: 360,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isLoading && <li style={{ padding: 12, color: '#888' }}>Загрузка...</li>}
|
<Surface elevation="sm" padding="none">
|
||||||
{!isLoading && results && results.length === 0 && (
|
{isLoading && (
|
||||||
<li style={{ padding: 12, color: '#888' }}>Ничего не найдено</li>
|
<Box sx={{ p: 1.5 }}>
|
||||||
)}
|
<Text tone="muted">Загрузка...</Text>
|
||||||
{!isLoading &&
|
</Box>
|
||||||
results?.map((item) => (
|
)}
|
||||||
<li
|
{!isLoading && results && results.length === 0 && (
|
||||||
key={item.secid}
|
<Box sx={{ p: 1.5 }}>
|
||||||
onClick={() => {
|
<Text tone="muted">Ничего не найдено</Text>
|
||||||
setOpen(false);
|
</Box>
|
||||||
setQuery('');
|
)}
|
||||||
navigate(
|
{!isLoading &&
|
||||||
item.type === 'share' ? `/stocks/${item.secid}` : `/bonds/${item.secid}`,
|
results?.map((item) => (
|
||||||
);
|
<Box
|
||||||
}}
|
key={item.secid}
|
||||||
style={{
|
onClick={() => {
|
||||||
padding: '10px 12px',
|
setOpen(false);
|
||||||
cursor: 'pointer',
|
setQuery('');
|
||||||
borderBottom: '1px solid #f0f0f0',
|
navigate(
|
||||||
display: 'flex',
|
item.type === 'share' ? `/stocks/${item.secid}` : `/bonds/${item.secid}`,
|
||||||
justifyContent: 'space-between',
|
);
|
||||||
alignItems: 'center',
|
}}
|
||||||
}}
|
sx={{
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#f5f5f5')}
|
display: 'flex',
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = '')}
|
justifyContent: 'space-between',
|
||||||
>
|
alignItems: 'center',
|
||||||
<span>
|
px: 1.5,
|
||||||
<strong>{item.shortName}</strong>
|
py: 1.25,
|
||||||
<span style={{ marginLeft: 8, color: '#888', fontSize: 12 }}>{item.secid}</span>
|
cursor: 'pointer',
|
||||||
</span>
|
borderBottom: 1,
|
||||||
<span
|
borderColor: 'divider',
|
||||||
style={{ fontSize: 12, color: item.type === 'share' ? '#1976d2' : '#2e7d32' }}
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
'&:last-child': { borderBottom: 0 },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{item.type === 'share' ? 'Акция' : 'Облигация'}
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
</span>
|
<Text>{item.shortName}</Text>
|
||||||
</li>
|
<Text variant="caption" tone="muted">
|
||||||
))}
|
{item.secid}
|
||||||
</ul>
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
label={item.type === 'share' ? 'Акция' : 'Облигация'}
|
||||||
|
tone={item.type === 'share' ? 'info' : 'success'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Surface>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
144
docs/features/broker-account-sections-ds-migration/plan.md
Normal file
144
docs/features/broker-account-sections-ds-migration/plan.md
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
# Дизайн-системный рефакторинг брокерских страниц — Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
|
||||||
|
> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use
|
||||||
|
> checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Удалить legacy-стили из страниц и компонентов брокерского раздела: CSS-классы,
|
||||||
|
`inline styles`, `SkeletonBlock`, `TableSkeleton` → перевести на `@moex-vibe/design-system`.
|
||||||
|
|
||||||
|
**Architecture:** Бизнес-логика, контракты и маршрутизация меняться не будут.
|
||||||
|
Градиенты героических секций, которые не имеют DS-эквивалента, сохраняются как `sx`-инлайн-стили.
|
||||||
|
SVG-диаграмма (`BrokerAllocationChart`) сохраняется как есть, меняются только обёртки.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18, MUI 6.5, `@moex-vibe/design-system` 0.1.0, CSS custom properties.
|
||||||
|
|
||||||
|
**Связанные документы:**
|
||||||
|
|
||||||
|
- Epic: `docs/epics/BrokerPortfolio.md`
|
||||||
|
- Функциональная базовая фича: `docs/features/broker-account-sections/spec.md`
|
||||||
|
- Research: `docs/research/2026-06-18-broker-account-sections.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: BrokerAccountLayout
|
||||||
|
|
||||||
|
Заменить layout shell на DS.
|
||||||
|
|
||||||
|
- `div.broker-account` → `<Box component="div">`
|
||||||
|
- `header.broker-account__header` → `<Box>` flex
|
||||||
|
- `h1` → `<Heading level={1}>`
|
||||||
|
- `nav.broker-account__navigation` → `<Box component="nav">`
|
||||||
|
- `NavLink` классы → при оставлении `className` callback (уникальный prefix для active state), но в первую очередь сменить на `Box` + `<Button>` или DS Tabs если доступен (DS Tabs/doc check)
|
||||||
|
- `div.broker-account__workspace` → `<Box>` grid (desktop sidebar + mobile row)
|
||||||
|
- `div.broker-account__content` → `<Box>` c overflow-auto
|
||||||
|
- Удалить CSS для `.broker-account__header`, `.broker-account__workspace`, `.broker-account__navigation`, `.broker-account__link`, `.broker-account__content` в `styles.css`
|
||||||
|
|
||||||
|
## Task 2: BrokerAccountOverviewPage, BrokerSummary, BrokerAssetCards
|
||||||
|
|
||||||
|
Перевести три связанных компонента overview на DS.
|
||||||
|
|
||||||
|
- `div.broker-overview` → `<Box component="div">`
|
||||||
|
- `section.broker-overview__summary` → `<Box>` grid
|
||||||
|
- `div.broker-overview__card` + inline-спаны → `<Box>` (card container) + `<Text>`
|
||||||
|
- `<span className="broker-overview__label">` → `<Text variant="label" tone="secondary">`
|
||||||
|
- `<strong>` значения → `<Text sx={{ fontWeight: 700 }}>`
|
||||||
|
- `ul.broker-overview__cash` → `<Box component="ul">` + `<Box component="li">`
|
||||||
|
- `section.broker-overview__assets` → `<Box>` grid
|
||||||
|
- `Link.broker-overview__asset-link` → `<Link>` + `<Box>` для layout карточки
|
||||||
|
- `<strong className="broker-overview__asset-title">` → `<Text sx={{ fontWeight: 700 }}>`
|
||||||
|
- Спаны значений/процентов → `<Box component="span">` + `<Text>`
|
||||||
|
- Оставить hero-gradient (`broker-overview__hero`-style) на caretaker'е (см. Out of scope)
|
||||||
|
|
||||||
|
## Task 3: BrokerOverviewSkeleton
|
||||||
|
|
||||||
|
Заменить старые skeleton на DS-component.
|
||||||
|
|
||||||
|
- Импорт `SkeletonBlock` → DS `Skeleton`
|
||||||
|
- `div.broker-overview__card` → `<Box>` для контейнера skeleton-элементов
|
||||||
|
- `SkeletonBlock height={...} width={...}` → `<Skeleton height={...} width={...} shape="text">`
|
||||||
|
- `SkeletonBlock borderRadius={80}` → `<Skeleton height={160} width={160} shape="circular">`
|
||||||
|
|
||||||
|
## Task 4: BrokerAllocationChart (wrap only)
|
||||||
|
|
||||||
|
Оставить SVG без изменений, мигрировать только обёртки.
|
||||||
|
|
||||||
|
- `figure.broker-allocation` → `<Box component="figure">`
|
||||||
|
- `figcaption` → `<Box component="figcaption">`
|
||||||
|
- `<p>` в пустом состоянии → `<Text tone="muted">`
|
||||||
|
- `<ul>` / `<li>` легенды → `<Box component="ul">` + `<Box component="li">`
|
||||||
|
- `span.broker-allocation__swatch` → `<Box}` с inline-цветом в `sx`
|
||||||
|
- Спаны текста легенды → `<Text>` или `<Box component="span">` с кастомным `fontSize`
|
||||||
|
- `ul.broker-allocation__negative` → `<Box component="ul">`
|
||||||
|
- Удалить CSS для `.broker-allocation*` в `styles.css` (кроме SVG-правил, которые оставить нетронутыми или перенести в inline)
|
||||||
|
|
||||||
|
## Task 5: BrokerPositionTable, PositionTicker
|
||||||
|
|
||||||
|
Миграция таблицы позиций.
|
||||||
|
|
||||||
|
- Вспомогательные типы для `headerStyle`, `cellStyle`, `pagButtonStyle` → MUI `sx` через `<Box>`, `<Button>`
|
||||||
|
- Импорт `SkeletonBlock` в компоненте → удалить, использовать `Skeleton` через `<TableSkeleton>` (см. Task 6)
|
||||||
|
- `h2` inline-стили → `<Heading level={2}>` с кастомной `sx` для размера/масштаба
|
||||||
|
- Ошибки/пустые `<p>` → `<Text>` через Box с inline-цветом
|
||||||
|
- `PositionTicker`: `<Link style={{ fontWeight: 700 }}>` → `<Link>` с `<Text sx={{ fontWeight: 700 }}>` внутри
|
||||||
|
- `table` → `<table component="table">` или оставить нативным table, но стилизовать через `sx`
|
||||||
|
- Сохранить `loading-spinner` класс в `styles.css` (глобальный):
|
||||||
|
- `TableSkeleton` → `<Skeleton>` after DS migration
|
||||||
|
- Keep `table-container` as minimal CSS or remove; convert `loading-overlay` to Box.
|
||||||
|
|
||||||
|
## Task 6: TableSkeleton
|
||||||
|
|
||||||
|
Заменить импорт `SkeletonBlock` на DS `Skeleton`.
|
||||||
|
|
||||||
|
- Убрать импорт `SkeletonBlock`, заменить на `Skeleton` из `@moex-vibe/design-system`.
|
||||||
|
- `SkeletonBlock height={12} width={col.width}` → `<Skeleton height={12} width={col.width} shape="text"/>`.
|
||||||
|
|
||||||
|
## Task 7: BrokerOperationsTable
|
||||||
|
|
||||||
|
Миграция таблицы операций.
|
||||||
|
|
||||||
|
- Вспомогательные стили (`tableStyle`, `thStyle`, `tdStyle`, `pagButtonStyle`) → inline-`sx` или `<Box>`
|
||||||
|
- Оставить `<select>` нативный, но обёрнуть в `<Box component="label">`
|
||||||
|
- `h2 inline` → `<Heading level={2}>` с `sx={{ fontSize: 20 }}`
|
||||||
|
- `<span role="status">` с `loading-spinner` → `<Skeleton shape="circular" width={14} height={14} />` (если DS поддерживает) или оставить `<span className="loading-spinner">` временно; DS `Skeleton` не имеет circular-only без width/height — оставить на этой итерации, можно оставить loading-spinner, так как это глобальный анимационный класс, а не DS.
|
||||||
|
- Пустые состояния / alert-тексты → `<Text tone="muted">` / `<Text tone="negative">`
|
||||||
|
- Пагинация `<button>` → `<Button variant="secondary">`
|
||||||
|
- `aria-label` — сохранить.
|
||||||
|
|
||||||
|
## Task 8: BrokerPositionsPage, BrokerOperationsPage
|
||||||
|
|
||||||
|
Миграция "обёрточных" страниц-секций.
|
||||||
|
|
||||||
|
- `section` → `<Box component="section">`
|
||||||
|
- `h2 inline` → `<Heading level={2}>` с `sx={{ fontSize: 20 }}`
|
||||||
|
- `p role="alert"` → `<Text tone="negative" component="p">` (или `<Box component="p">`)
|
||||||
|
- `p.empty` → `<Text tone="muted">`
|
||||||
|
- `div.broker-operations__toolbar` → `<Box>` с flex
|
||||||
|
- `label > span + select` → `<Box component="label">` + `<Text>` + `<select>`
|
||||||
|
|
||||||
|
## Task 9: SkeletonBlock удаление
|
||||||
|
|
||||||
|
После замены всех потребителей:
|
||||||
|
|
||||||
|
1. Удалить `apps/frontend/src/shared/ui/SkeletonBlock.tsx`.
|
||||||
|
2. Удалить экспорт из `apps/frontend/src/shared/ui/index.ts`.
|
||||||
|
3. Убедиться, что `import { SkeletonBlock }` не осталось нигде.
|
||||||
|
|
||||||
|
## Task 10: Очистка `styles.css`
|
||||||
|
|
||||||
|
- Удалить все selector'ы из блока `broker-account__*`, `broker-overview__*`, `broker-allocation__*` (SVG стили оставить), `broker-operations__toolbar*`.
|
||||||
|
- Оставить косметические классы, которые ещё не мигрированы (если таковые возникнут).
|
||||||
|
- Оставить `.loading-spinner` (глобальный анимационный класс, применяется через `className` где-то еще).
|
||||||
|
- Оставить `:root` CSS variables — они DS взаиморасчётные токены.
|
||||||
|
|
||||||
|
## Task 11: Тестирование и сборка
|
||||||
|
|
||||||
|
- [ ] `npm run test:frontend` — все тесты PASS.
|
||||||
|
- [ ] `npm run lint -w apps/frontend` — без ошибок.
|
||||||
|
- [ ] `npm run build:frontend` — без TypeScript/Vite ошибок.
|
||||||
|
- [ ] Проверить, что нет остатков `SkeletonBlock` импортов в кодовой базе: `grep -r "SkeletonBlock" apps/frontend/src`.
|
||||||
|
- [ ] Проверить, что нет остатков `broker-account__*`, `broker-overview__*`, `broker-allocation__*`, `broker-operations__toolbar` в TSX: `grep -rE "broker-(account|overview|allocation|operations)" apps/frontend/src --include='*.{ts,tsx}'`.
|
||||||
|
|
||||||
|
## Task 12: Вердикт и записка
|
||||||
|
|
||||||
|
- [ ] Обновить `docs/features/broker-account-sections-ds-migration/tasks.md` — отметить все выполненные пункты.
|
||||||
85
docs/features/broker-account-sections-ds-migration/spec.md
Normal file
85
docs/features/broker-account-sections-ds-migration/spec.md
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
# Дизайн-системный рефакторинг страниц брокерского счёта
|
||||||
|
|
||||||
|
Дата: 2026-06-21
|
||||||
|
Статус: реализовано
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Epic `Брокерский портфель` уже покрыт функциональной фичей `broker-account-sections` (реализовано 2026-06-18). Текущая цель — убрать legacy-стили (CSS-классы, inline-стили, `SkeletonBlock`, `loading-spinner`) из страниц и компонентов, относящихся к одному выбранному брокерскому счёту, и перевести их на `@moex-vibe/design-system`.
|
||||||
|
|
||||||
|
Страницы в области изменения:
|
||||||
|
|
||||||
|
- `BrokerAccountLayout` — навигация и заголовок счёта;
|
||||||
|
- `BrokerAccountOverviewPage` — карточки сводки, диаграмма, последние операции;
|
||||||
|
- `BrokerSummary` — текстовые показатели портфеля и денежных остатков;
|
||||||
|
- `BrokerAssetCards` — карточки акций и облигаций;
|
||||||
|
- `BrokerOverviewSkeleton` — loading-состояние overview;
|
||||||
|
- `BrokerAllocationChart` — SVG-диаграмма (диаграмма остаётся SVG, меняются обёртки);
|
||||||
|
- `BrokerPositionTable` — таблица позиций с пагинацией;
|
||||||
|
- `BrokerOperationsTable` — таблица операций с пагинацией;
|
||||||
|
- `PositionTicker` — кликабельный тикер в таблице позиций;
|
||||||
|
- `BrokerPositionsPage` — страница акций / облигаций;
|
||||||
|
- `BrokerOperationsPage` — страница истории операций с фильтром.
|
||||||
|
|
||||||
|
## Связанные фичи
|
||||||
|
|
||||||
|
- Epic: `docs/epics/BrokerPortfolio.md`
|
||||||
|
- Функциональная базовая фича: `docs/features/broker-account-sections/spec.md`
|
||||||
|
- DS-пакет: `packages/design-system/`
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Удалить все CSS-классы (кроме героических градиентов, где нет DS-эквивалента) и `SkeletonBlock` из страниц и компонентов, относящихся к одному брокерскому счёту. Перевести layout, typography, skeleton, метрики, алерты, кнопки, пустые состояния на `@moex-vibe/design-system`. Оставить нетронутыми бизнес-логику, контракты и схему маршрутизации.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Изменение бизнес-логики, контрактов API, entity-слоя;
|
||||||
|
- Изменение маршрутизации;
|
||||||
|
- Изменение SVG-диаграммы;
|
||||||
|
- Рефакторинг broker-accounts-page (список счетов) — отдельная фича;
|
||||||
|
- Изменение CSS-переменных `:root` и их использования за пределами страниц брокера;
|
||||||
|
- Добавление новых тестов (существующие тесты сохраняются, если их контракт не меняется).
|
||||||
|
|
||||||
|
## Принципы миграции
|
||||||
|
|
||||||
|
- `SkeletonBlock` → DS `Skeleton` (все потребители → удаление `SkeletonBlock` из `shared/ui`);
|
||||||
|
- `<h1>`/`<h2>` → `<Heading level={1|2}>`;
|
||||||
|
- `<p label>`, `<span>` → `<Text variant="label" tone="secondary">`;
|
||||||
|
- `<p>` статические тексты → `<Text variant="body">` или `<Box component="span">` с `sx` (если нужен кастомный размер/цвет);
|
||||||
|
- `<strong>` значения → `<Text variant="body" sx={{ fontWeight: 700 }}>`;
|
||||||
|
- Inline `<p role="alert">` → `<Text tone="negative">`;
|
||||||
|
- Стандартный `<section>` → `<Box component="section">`;
|
||||||
|
- Hero-gradient `broker-overview` → `<Box>` с кастомным `sx` (градиент остаётся как inline-стиль);
|
||||||
|
- `div.broker-operations__toolbar` → `<Box>` с flex-сеткой;
|
||||||
|
- `<select>` элемент — оставить как нативный `<select>`, обёрнуть в `<Box>`.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] `BrokerAccountLayout` не содержит CSS-классов из `styles.css`; используется `<Heading>`, `<Text>`, `<Box>`.
|
||||||
|
- [x] `BrokerAccountOverviewPage` и содержащиеся в нём `BrokerSummary`, `BrokerAssetCards` переведены на DS-компоненты.
|
||||||
|
- [x] `BrokerOverviewSkeleton` использует DS `<Skeleton>`.
|
||||||
|
- [x] `BrokerAllocationChart` обёртки (`<figure>`, `<figcaption>`) переведены на `<Box>`; текст легенды — на `<Text>`.
|
||||||
|
- [x] `BrokerPositionTable` и `BrokerOperationsTable` используют `<Box>` для layout, `<Heading>` для заголовков, `<Button>` или `<Box>` для пагинации, пустые и loading-состояния — через DS.
|
||||||
|
- [x] `TableSkeleton` переведён на DS `<Skeleton>`.
|
||||||
|
- [x] `SkeletonBlock` удалён после замены всех потребителей.
|
||||||
|
- [x] `BrokerPositionsPage` и `BrokerOperationsPage` переведены на `<Box>`, `<Heading>`, `<Text>`.
|
||||||
|
- [x] `PositionTicker` — `<Link>` с `<Box component="span" sx={{ fontWeight: 700 }}>`.
|
||||||
|
- [x] ESLint allowlist не нарушен (`no-restricted-imports` разрешает `Box`, `Stack`, `Grid` из `@mui/material` barrel).
|
||||||
|
- [x] `npm run test:frontend && npm run lint -w apps/frontend && npm run build:frontend` проходят.
|
||||||
|
- [x] CSS-классы, оставшиеся без потребителей, удалены из `styles.css` (но сохраняются градиенты hero-секций).
|
||||||
|
|
||||||
|
## Результаты реализации
|
||||||
|
|
||||||
|
Все 12 задач плана выполнены и закоммичены в ветку `codex/broker-accounts-page` (13 коммитов). Ключевые изменения:
|
||||||
|
|
||||||
|
- **Полностью мигрированы на DS:** `BrokerAccountLayout`, `BrokerAccountOverviewPage`, `BrokerSummary`, `BrokerAssetCards`, `BrokerOverviewSkeleton`, `BrokerAllocationChart`, `BrokerPositionTable`, `BrokerOperationsTable`, `BrokerPositionsPage`, `BrokerOperationsPage`, `PositionTicker`.
|
||||||
|
- **Удалён legacy:** `SkeletonBlock` удалён из `shared/ui/SkeletonBlock.tsx` и `shared/ui/index.ts`.
|
||||||
|
- **Очистка CSS:** все `broker-account__*`, `broker-overview__*`, `broker-allocation__*`, `broker-operations__toolbar*` классы и переменные удалены из `styles.css`. CSS уменьшился с 274 до 99 строк.
|
||||||
|
- Верификация: 111 тестов PASS, lint чистый, build проходит без ошибок.
|
||||||
|
|
||||||
|
### Отклонения от плана
|
||||||
|
|
||||||
|
- `PositionTicker`: использован `<Box component="span">` вместо DS `<Text>` с `sx`, так как DS `Text` не поддерживает кастомный `sx`.
|
||||||
|
- `TableSkeleton`: сохранён как shared компонент (вместо удаления), так как используется `BrokerOperationsTable`.
|
||||||
|
- `loading-spinner`: оставлен как глобальный CSS-класс (генерируется через `<Box className="loading-spinner">`). DS `Skeleton shape="circular"` использован для inline-спиннеров в пагинации.
|
||||||
|
- `TableSkeleton` type signature остался без изменений (все уже импортируют `Skeleton` из DS, не `SkeletonBlock`).
|
||||||
101
docs/features/broker-account-sections-ds-migration/tasks.md
Normal file
101
docs/features/broker-account-sections-ds-migration/tasks.md
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# Дизайн-системный рефакторинг брокерских страниц — Tasks
|
||||||
|
|
||||||
|
Дата: 2026-06-21
|
||||||
|
Связанные документы:
|
||||||
|
|
||||||
|
- [Spec](spec.md)
|
||||||
|
- [Plan](plan.md)
|
||||||
|
|
||||||
|
## Task 1: BrokerAccountLayout
|
||||||
|
|
||||||
|
- [x] Заменить `div.broker-account` на `<Box component="div">`
|
||||||
|
- [x] Перевести `header` + `h1` на `<Box>` + `<Heading level={1}>`
|
||||||
|
- [x] Перевести `nav` на `<Box component="nav">` + `<NavLink>` (Box component={NavLink} с `aria-current="page"`)
|
||||||
|
- [x] Перевести `div.broker-account__workspace` на grid `<Box>`
|
||||||
|
- [x] Перевести `div.broker-account__content` на `<Box>`
|
||||||
|
- [x] Удалить соответствующие CSS-selectors из `styles.css`
|
||||||
|
|
||||||
|
## Task 2: BrokerAccountOverviewPage + BrokerSummary + BrokerAssetCards
|
||||||
|
|
||||||
|
- [x] Перевести `BrokerSummary` на `<Box>` + `<Text>`
|
||||||
|
- [x] Перевести eyebrow/label на `<Text variant="label" tone="secondary">`
|
||||||
|
- [x] Перевести `<strong>` значений на `<Box component="span" sx={{ fontWeight: 700 }}>`
|
||||||
|
- [x] Перевести `<ul>` cash на `<Box component="ul">`
|
||||||
|
- [x] Перевести `BrokerAssetCards` на `<Box>` + `<Link>` + `<Text>`
|
||||||
|
- [x] Перевести `BrokerAccountOverviewPage` container на `<Box component="div">`
|
||||||
|
- [x] Перевести `<p role="alert">` на `<Text component="p" tone="negative">`
|
||||||
|
- [x] Оставить hero-gradient без изменений (Out of scope)
|
||||||
|
|
||||||
|
## Task 3: BrokerOverviewSkeleton
|
||||||
|
|
||||||
|
- [x] Заменить импорт `SkeletonBlock` на DS `<Skeleton>`
|
||||||
|
- [x] Перевести контейнеры на `<Box>`
|
||||||
|
- [x] Сделать circular skeleton для портфельной карточки
|
||||||
|
- [x] Сделать text skeleton для текстовых placeholder'ей
|
||||||
|
|
||||||
|
## Task 4: BrokerAllocationChart (wrap only)
|
||||||
|
|
||||||
|
- [x] Перевести `<figure>` на `<Box component="figure">`
|
||||||
|
- [x] Перевести `<figcaption>` на `<Box component="figcaption">`
|
||||||
|
- [x] Перевести `<p>` пустого состояния на `<Text tone="muted">`
|
||||||
|
- [x] Перевести `<ul>` / `<li>` легенды на `<Box component="ul">` + `<Box component="li">`
|
||||||
|
- [x] Перевести swatch на `<Box>` с inline-цветом в `sx`
|
||||||
|
- [x] Перевести тексты легенды на `<Box component="span">`
|
||||||
|
- [x] Перевести negative-список на `<Box component="ul">`
|
||||||
|
- [x] Удалить CSS для `.broker-allocation*` в `styles.css`
|
||||||
|
|
||||||
|
## Task 5: BrokerPositionTable + PositionTicker
|
||||||
|
|
||||||
|
- [x] Перевести вспомогательные стили на `<Box sx={{...}}>`
|
||||||
|
- [x] Перевести `<h2>` на `<Heading level={2}>`
|
||||||
|
- [x] Перевести alert-тексты на `<Text tone="negative">`
|
||||||
|
- [x] Перевести пагинационные `<button>` на `<Button variant="secondary">`
|
||||||
|
- [x] Перевести `loading-overlay` на `<Box>`
|
||||||
|
- [x] Перевести `PositionTicker` ссылки на `<Box component="span" sx={{ fontWeight: 700 }}>`
|
||||||
|
- [x] Сохранить `loading-spinner` (глобальный класс, пока без замены)
|
||||||
|
|
||||||
|
## Task 6: TableSkeleton (DS Skeleton)
|
||||||
|
|
||||||
|
- [x] Убрать импорт `SkeletonBlock`
|
||||||
|
- [x] Заменить на `<Skeleton height={12} width={col.width} shape="text" />`
|
||||||
|
|
||||||
|
## Task 7: BrokerOperationsTable
|
||||||
|
|
||||||
|
- [x] Перевести вспомогательные стили на `<Box sx={{...}}>`
|
||||||
|
- [x] Перевести `<h2>` на `<Heading level={2}>`
|
||||||
|
- [x] Перевести `headerAction` container на `<Box>`
|
||||||
|
- [x] Перевести пагинационные `<button>` на `<Button variant="secondary">`
|
||||||
|
- [x] Перевести alert-тексты на `<Text tone="negative">`
|
||||||
|
- [x] Перевести empty-тексты на `<Text tone="muted">`
|
||||||
|
- [x] Перевести `<label>` + `<select>` на `<Box component="label">` + `<select>`
|
||||||
|
- [x] Оставить `loading-spinner` для overlay (глобальный класс)
|
||||||
|
|
||||||
|
## Task 8: BrokerPositionsPage + BrokerOperationsPage
|
||||||
|
|
||||||
|
- [x] Перевести `BrokerPositionsPage` на `<Box component="section">` + `<Heading>` + `<Text>`
|
||||||
|
- [x] Перевести `BrokerOperationsPage` на `<Box>` + `<Heading>` + `<Box component="label">` + `<select>`
|
||||||
|
- [x] Убедиться, что `role="alert"` сохраняется через `<Text tone="negative">`
|
||||||
|
|
||||||
|
## Task 9: SkeletonBlock удаление
|
||||||
|
|
||||||
|
- [x] Удалить `apps/frontend/src/shared/ui/SkeletonBlock.tsx`
|
||||||
|
- [x] Удалить экспорт из `apps/frontend/src/shared/ui/index.ts`
|
||||||
|
- [x] Проверить grep по `SkeletonBlock` — 0 совпадений
|
||||||
|
|
||||||
|
## Task 10: Очистка `styles.css`
|
||||||
|
|
||||||
|
- [x] Удалить блоки `.broker-account__*`
|
||||||
|
- [x] Удалить блоки `.broker-overview__*`
|
||||||
|
- [x] Удалить блоки `.broker-allocation__*`
|
||||||
|
- [x] Удалить блоки `.broker-operations__toolbar*`
|
||||||
|
- [x] Удалить CSS-переменные `--broker-overview-*`
|
||||||
|
- [x] Оставить `.loading-spinner`, `.skeleton`, `:root` общие переменные
|
||||||
|
|
||||||
|
## Task 11: Финальная проверка
|
||||||
|
|
||||||
|
- [x] `npm run test:frontend` — 111 tests PASS
|
||||||
|
- [x] `npm run lint -w apps/frontend` — без ошибок
|
||||||
|
- [x] `npm run build:frontend` — без TypeScript/Vite ошибок
|
||||||
|
- [x] grep по `broker-(account|overview|allocation)` в TSX — 0 совпадений
|
||||||
|
- [x] grep по `SkeletonBlock` в TSX — 0 совпадений
|
||||||
|
- [x] CSS: 274 → 99 строк
|
||||||
94
docs/features/broker-accounts-page-migration/plan.md
Normal file
94
docs/features/broker-accounts-page-migration/plan.md
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# Broker Accounts Page — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Migrate broker-accounts page area to use `@moex-vibe/design-system` components.
|
||||||
|
|
||||||
|
**Architecture:** Replace legacy CSS classes, inline styles, and shared/components with DS equivalents. Keep business logic and data layer unchanged. The hero gradient section is intentionally left as custom CSS.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18, MUI 6.5, `@moex-vibe/design-system` 0.1.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
### Modify
|
||||||
|
- `apps/frontend/src/pages/broker-accounts/ui/BrokerAccountsPage.tsx`
|
||||||
|
- `apps/frontend/src/widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx`
|
||||||
|
- `apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx`
|
||||||
|
- `apps/frontend/src/shared/ui/broker-allocation-bar/BrokerAllocationBar.tsx`
|
||||||
|
- `apps/frontend/src/styles.css` — удалить осиротевшие классы
|
||||||
|
|
||||||
|
### No changes
|
||||||
|
- Entity layer (`entities/broker-account/`, `entities/broker-position/`) — бизнес-логика не меняется
|
||||||
|
- Shared formatters (`shared/lib/formatters.ts`) — используются для форматирования значений
|
||||||
|
- Shared API layer (`shared/api/`) — не меняется
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: BrokerAllocationBar
|
||||||
|
|
||||||
|
Replace inline HTML + CSS classes with `<Box>` + `<Text>`.
|
||||||
|
|
||||||
|
- Empty: `<p className="broker-allocation-bar__empty">` → `<Text tone="muted">`
|
||||||
|
- Track: `<div className="broker-allocation-bar__track">` → `<Box>` with `sx`
|
||||||
|
- Segment: `<span style={{ width, background }}>` → `<Box>` with `sx`
|
||||||
|
- Legend: `<ul>` / `<li>` → `<Box>` with `sx` flex layout
|
||||||
|
- Swatch: `<span>` with inline style → `<Box>` with `sx`
|
||||||
|
|
||||||
|
Segment width/background и swatch colors остаются как inline styles (dynamic per item, from ALLOCATION_CONFIG).
|
||||||
|
|
||||||
|
## Task 2: BrokerAccountCard
|
||||||
|
|
||||||
|
Replace legacy shared components and CSS with DS equivalents.
|
||||||
|
|
||||||
|
- Skeleton block: `<SkeletonBlock>` → `<Skeleton>` from DS
|
||||||
|
- Eyebrow: `<p className="broker-account-card__eyebrow">` → `<Text variant="label" tone="secondary">`
|
||||||
|
- Title: `<h2 className="broker-account-card__title">` → `<Heading level={2}>`
|
||||||
|
- Stat labels/values: `<span>` + `<strong>` → `<Text variant="label" tone="secondary">` + `<Text>`
|
||||||
|
- Card wrapper: `<article className="broker-account-card broker-account-card--link">` → `<Surface>` with `component={Link}` or `<Box>` with `sx`
|
||||||
|
- Error: `<article className="broker-account-card broker-account-card--error">` → `<Surface>` + `<Alert>` + `<Button>`
|
||||||
|
- Card header layout: `<div className="broker-account-card__header">` → `<Box>` with flex `sx`
|
||||||
|
- Card grid: `<div className="broker-account-card__grid">` → `<Box>` with grid `sx`
|
||||||
|
- Card stat: `<div className="broker-account-card__stat">` → `<Box>` with grid `sx`
|
||||||
|
|
||||||
|
## Task 3: BrokerAccountsSummary
|
||||||
|
|
||||||
|
Replace skeleton, stats, typography with DS equivalents.
|
||||||
|
|
||||||
|
- Skeleton: `<SkeletonBlock>` → `<Skeleton>`
|
||||||
|
- Eyebrow: `<p className="broker-accounts-summary__eyebrow">` → `<Text variant="label" tone="secondary">`
|
||||||
|
- Title (serif): keep as `<h2>` with CSS class (DS Heading не использует serif, теряется brand-стиль)
|
||||||
|
- Status counts: `<span>` → `<Text>`
|
||||||
|
- Currency label: `<p className="broker-accounts-summary__currency">` → `<Text variant="label" tone="secondary">`
|
||||||
|
- Currency totals: `<strong className="broker-accounts-summary__total">` — keep as `<strong>` with CSS (serif)
|
||||||
|
- Metrics: `<dl>`/`<dt>`/`<dd>` → `<Metric>` for each stat
|
||||||
|
- Metric layout grid: `<div className="broker-accounts-summary__metrics">` → `<Box>` with grid `sx`
|
||||||
|
|
||||||
|
The hero section (`broker-accounts-summary__hero`) keeps its CSS classes — custom gradient, not replaceable.
|
||||||
|
|
||||||
|
## Task 4: BrokerAccountsPage
|
||||||
|
|
||||||
|
Replace page-level layout, typography, empty/error states.
|
||||||
|
|
||||||
|
- Page grid: `<div className="broker-accounts-page">` → `<Box>` with grid `sx`
|
||||||
|
- Header: `<header className="broker-accounts-page__header">` → `<Box>` with flex `sx`
|
||||||
|
- Eyebrow: `<p className="broker-accounts-page__eyebrow">` → `<Text variant="label" tone="secondary">`
|
||||||
|
- Title: `<h1 className="broker-accounts-page__title">` → `<Heading level={1}>` (note: serif lost)
|
||||||
|
- Caption: `<p className="broker-accounts-page__caption">` → `<Text tone="secondary">`
|
||||||
|
- Cards grid: `<div className="broker-accounts-page__cards">` → `<Box>` with grid `sx`
|
||||||
|
- Empty: `<section className="broker-accounts-empty">` → `<EmptyState>`
|
||||||
|
- Error: `<p style={{ color: ... }}>` → `<Text tone="negative">`
|
||||||
|
|
||||||
|
## Task 5: Clean up orphaned CSS
|
||||||
|
|
||||||
|
After all migrations, remove from `styles.css`:
|
||||||
|
- `.broker-accounts-page`, `.broker-accounts-page__header`, `.broker-accounts-page__eyebrow`, `.broker-accounts-page__title`, `.broker-accounts-page__caption`, `.broker-accounts-page__cards`
|
||||||
|
- `.broker-accounts-empty`
|
||||||
|
- `.broker-account-card`, `.broker-account-card--link`, `.broker-account-card--loading`, `.broker-account-card--error`, `.broker-account-card__header`, `.broker-account-card__eyebrow`, `.broker-account-card__title`, `.broker-account-card__opened`, `.broker-account-card__grid`, `.broker-account-card__stat`, `.broker-account-card__stat--wide`, `.broker-account-card__alert`, `.broker-account-card__retry`
|
||||||
|
- `.broker-allocation-bar`, `.broker-allocation-bar__track`, `.broker-allocation-bar__segment`, `.broker-allocation-bar__legend`, `.broker-allocation-bar__legend-item`, `.broker-allocation-bar__swatch`, `.broker-allocation-bar__empty`
|
||||||
|
- `.broker-accounts-summary__eyebrow`, `.broker-accounts-summary__currency`, `.broker-accounts-summary__total`, `.broker-accounts-summary__metrics`, `.broker-accounts-summary__metric`
|
||||||
|
- `.broker-accounts-summary__currency-card` and its children (header, currency, total, metrics, metric)
|
||||||
|
- `.broker-accounts-summary__status` and children
|
||||||
|
|
||||||
|
Keep: `.broker-accounts-summary`, `.broker-accounts-summary--loading`, `.broker-accounts-summary__hero`, `.broker-accounts-summary__title` (serif), shared `.broker-accounts-summary__eyebrow` shared rule (hero-specific), `.broker-accounts-summary__currency-grid`
|
||||||
54
docs/features/broker-accounts-page-migration/spec.md
Normal file
54
docs/features/broker-accounts-page-migration/spec.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# Broker Accounts Page — Design System Migration
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Implemented — 2026-06-21.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Мигрировать BrokerAccountsPage, BrokerAccountsSummary, BrokerAccountCard и BrokerAllocationBar на компоненты `@moex-vibe/design-system`, заменив legacy CSS и inline-стили на DS-компоненты и MUI layout utilities.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- `apps/frontend/src/pages/broker-accounts/ui/BrokerAccountsPage.tsx`
|
||||||
|
- `apps/frontend/src/widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx`
|
||||||
|
- `apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx`
|
||||||
|
- `apps/frontend/src/shared/ui/broker-allocation-bar/BrokerAllocationBar.tsx`
|
||||||
|
- `apps/frontend/src/styles.css` — удалить CSS-классы и переменные, оставшиеся без потребителей
|
||||||
|
|
||||||
|
### DS-компоненты для внедрения
|
||||||
|
|
||||||
|
| HTML / legacy | DS-компонент |
|
||||||
|
|-------------|-------------|
|
||||||
|
| `<h1>`, `<h2>` | `<Heading>` |
|
||||||
|
| `<p>`, `<span>` label | `<Text>` |
|
||||||
|
| `<span>` eyebrow | `<Text variant="label" tone="secondary">` |
|
||||||
|
| `<dl>`/`<dt>`/`<dd>` stat | `<Metric>` |
|
||||||
|
| `div.broker-accounts-empty` | `<EmptyState>` |
|
||||||
|
| `div.broker-account-card__alert` + button | `<Alert severity="error">` + `<Button variant="primary">` |
|
||||||
|
| `div.broker-accounts-summary__hero` title/text | `<Heading>` + `<Text>` |
|
||||||
|
| `.broker-account-card` card | `<Surface>` or `<Card>` |
|
||||||
|
| `<SkeletonBlock>` | `<Skeleton>` from DS |
|
||||||
|
| `div.broker-accounts-page` layout | `<Box>` with `sx` |
|
||||||
|
| `div.broker-accounts-page__header` | `<Box>` with flex |
|
||||||
|
| `div.broker-accounts-page__cards` | `<Box>` with grid |
|
||||||
|
| `p.broker-allocation-bar__empty` | `<Text tone="muted">` |
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Hero gradient section visual identity (too custom, no DS equivalent)
|
||||||
|
- Serif font family on titles (DS uses Inter)
|
||||||
|
- Allocation bar colors from ALLOCATION_CONFIG (business config, not tokens)
|
||||||
|
- Добавление новых тестов (entity-тесты существуют, page-тесты — новая фича)
|
||||||
|
- Рекламные/декоративные элементы (градиенты, кастомные скругления)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] BrokerAccountsPage не содержит CSS-классов из styles.css; `<h1>` / `<p>` заменены на `<Heading>` / `<Text>`
|
||||||
|
- [ ] BrokerAccountsPage empty state использует `<EmptyState>`, error — `<Text tone="negative">`
|
||||||
|
- [ ] BrokerAccountsSummary skeleton использует DS `<Skeleton>`, stats используют `<Metric>`, eyebrow/title используют `<Text>`/`<Heading>`
|
||||||
|
- [ ] BrokerAccountCard skeleton/error/success используют DS `<Skeleton>`, `<Alert>`, `<Button>`, `<Heading>`, `<Text>`, `<Metric>`, `<Surface>`
|
||||||
|
- [ ] BrokerAllocationBar использует `<Box>` + `<Text>` вместо div/p с CSS-классами
|
||||||
|
- [ ] ESLint allowlist не нарушен
|
||||||
|
- [ ] `npm run test:frontend && npm run lint -w apps/frontend && npm run build:frontend` проходят
|
||||||
|
- [ ] CSS-классы, оставшиеся без потребителей, удалены из styles.css
|
||||||
42
docs/features/broker-accounts-page-migration/tasks.md
Normal file
42
docs/features/broker-accounts-page-migration/tasks.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# Broker Accounts Page — Tasks
|
||||||
|
|
||||||
|
## Tasks Checklist
|
||||||
|
|
||||||
|
- [x] **Task 1: BrokerAllocationBar**
|
||||||
|
- [x] Replace `p.broker-allocation-bar__empty` with `<Text tone="muted">`
|
||||||
|
- [x] Replace `div.broker-allocation-bar` with `<Box>` container
|
||||||
|
- [x] Replace track and segment divs/spans with `<Box>` components with MUI `sx` styling
|
||||||
|
- [x] Replace legend and swatch elements with `<Box>` and `<Text>` components
|
||||||
|
- [x] Verify BrokerAllocationBar compile state
|
||||||
|
|
||||||
|
- [x] **Task 2: BrokerAccountCard**
|
||||||
|
- [x] Replace `<SkeletonBlock>` with `<Skeleton>` from `@moex-vibe/design-system`
|
||||||
|
- [x] Replace eyebrow with `<Text variant="label" tone="secondary">`
|
||||||
|
- [x] Replace title `h2` with `<Heading level={2}>`
|
||||||
|
- [x] Replace error alert block with `<Alert severity="error">` and a `<Button>`
|
||||||
|
- [x] Replace layout grids, stats, and card component with `<Box>` with `sx`
|
||||||
|
- [x] Set component on `<Box>` to `Link` from `react-router-dom` for card wrapper
|
||||||
|
- [x] Ensure stat elements use proper Text styles and spacing
|
||||||
|
|
||||||
|
- [x] **Task 3: BrokerAccountsSummary**
|
||||||
|
- [x] Replace skeleton placeholders with `<Skeleton>` from DS
|
||||||
|
- [x] Replace headers and subheaders with proper typography
|
||||||
|
- [x] Convert layout container to `<Box>` component with `sx` positioning
|
||||||
|
- [x] Replace metrics detail grid with Box layout using custom margins and spacing
|
||||||
|
- [x] Keep custom hero layout styles and replace text styling inside the hero with `<Box>` and custom variables to ensure colors map correctly
|
||||||
|
|
||||||
|
- [x] **Task 4: BrokerAccountsPage**
|
||||||
|
- [x] Replace layout containers and headers with `<Box>` with `sx` flex/grid
|
||||||
|
- [x] Replace main title with `<Heading level={1}>` and subtitle with `<Text>`
|
||||||
|
- [x] Replace empty state section with `<EmptyState>` component
|
||||||
|
- [x] Replace error styling with `<Text tone="negative">`
|
||||||
|
|
||||||
|
- [x] **Task 5: Clean up orphaned CSS**
|
||||||
|
- [x] Find and delete unused CSS selectors in `apps/frontend/src/styles.css`
|
||||||
|
- [x] Verify reduced motion and max-width 720px responsive rules are cleanly updated without leaving dead rules
|
||||||
|
- [x] Ensure we only leave rules for pages and components that have not yet been migrated
|
||||||
|
|
||||||
|
- [x] **Task 6: Verification**
|
||||||
|
- [x] Run `npm run test:frontend` and ensure all 111 tests pass
|
||||||
|
- [x] Run `npm run lint -w apps/frontend` to verify ESLint compatibility
|
||||||
|
- [x] Run `npm run build:frontend` to compile production assets and verify type safety
|
||||||
60
docs/features/pilot-migration/plan.md
Normal file
60
docs/features/pilot-migration/plan.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# Pilot Migration — Implementation Plan
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Миграция слоя представления без изменения логики. HomePage меняет JSX на DS-компоненты. SearchBar заменяет inline-стили на DS-компоненты + MUI `Box` (разрешён layout utility).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
### Modify
|
||||||
|
- `apps/frontend/src/pages/home/ui/HomePage.tsx` — замена JSX
|
||||||
|
- `apps/frontend/src/widgets/search-bar/ui/SearchBar.tsx` — замена JSX
|
||||||
|
|
||||||
|
### No changes
|
||||||
|
- `apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx` — тесты не меняются (те же тексты)
|
||||||
|
- `apps/frontend/src/styles.css` — CSS-переменные всё ещё используются broker-страницами
|
||||||
|
|
||||||
|
## Step-by-step
|
||||||
|
|
||||||
|
### Step 1: Migrate HomePage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// До
|
||||||
|
<div style={{ textAlign: 'center', paddingTop: 120 }}>
|
||||||
|
<h1 style={{ fontSize: 32, fontWeight: 700, marginBottom: 12 }}>MoexVibe</h1>
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)', fontSize: 16, marginBottom: 32 }}>
|
||||||
|
Анализ акций и облигаций Московской биржи
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#888', fontSize: 13 }}>Введите название или тикер в строку поиска выше</p>
|
||||||
|
<p style={{ color: '#aaa', fontSize: 12, marginTop: 8 }}>
|
||||||
|
Данные задерживаются на 15 минут · Бесплатный API MOEX ISS
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// После
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { Heading, Text } from '@moex-vibe/design-system';
|
||||||
|
|
||||||
|
<Box textAlign="center" pt={30}>
|
||||||
|
<Heading level={1} size="display">MoexVibe</Heading>
|
||||||
|
<Text tone="secondary">Анализ акций и облигаций Московской биржи</Text>
|
||||||
|
<Text tone="muted">Введите название или тикер в строку поиска выше</Text>
|
||||||
|
<Text tone="muted">Данные задерживаются на 15 минут · Бесплатный API MOEX ISS</Text>
|
||||||
|
</Box>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Migrate SearchBar
|
||||||
|
|
||||||
|
- `<input>` → `<TextField label="Поиск" hiddenLabel placeholder="Поиск акций и облигаций..." />`
|
||||||
|
- `<ul>` dropdown wrapper → `<Surface elevation="sm">` (с inline-стилями для absolute positioning)
|
||||||
|
- `<li>` loading → `<Text tone="muted">Загрузка...</Text>`
|
||||||
|
- `<li>` empty → `<Text tone="muted">Ничего не найдено</Text>`
|
||||||
|
- `<li>` result item → `<Box>` + `<Text>` + `<Chip>`
|
||||||
|
- Hover effect (`onMouseEnter/onMouseLeave`) → MUI `sx={{ '&:hover': { bgcolor: 'action.hover' } }}`
|
||||||
|
- Hardcoded hex colors → MUI theme tokens (`text.disabled`, `divider`, etc.)
|
||||||
|
|
||||||
|
### Step 3: Verify
|
||||||
|
|
||||||
|
- `npm run test:frontend` — все тесты проходят
|
||||||
|
- `npm run lint -w apps/frontend` — lint чист, нет неразрешённых MUI-импортов
|
||||||
|
- `npm run build:frontend` — сборка проходит
|
||||||
31
docs/features/pilot-migration/spec.md
Normal file
31
docs/features/pilot-migration/spec.md
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Pilot Migration — Design System Adoption
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Implemented — 2026-06-21.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Пилотная миграция существующих страниц на компоненты `@moex-vibe/design-system`. Подтвердить, что API дизайн-системы покрывает реальные сценарии, и зафиксировать gaps для следующей итерации.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- `apps/frontend/src/pages/home/ui/HomePage.tsx` — миграция на `<Heading>`, `<Text>`, `<Box>`
|
||||||
|
- `apps/frontend/src/widgets/search-bar/ui/SearchBar.tsx` — миграция на `<TextField>`, `<Surface>`, `<Chip>`, `<Text>`, `<Box>`
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Рефакторинг логики SearchBar (debounce, click outside, keyboard nav, navigate)
|
||||||
|
- Миграция других страниц/виджетов
|
||||||
|
- Добавление новых компонентов в DS
|
||||||
|
- Удаление всего `styles.css` (только orphaned CSS variables после миграции)
|
||||||
|
- Изменение поведения компонентов (все acceptance criteria остаются теми же)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] HomePage не содержит inline-стилей; все стили — через DS-компоненты и MUI layout utilities
|
||||||
|
- [ ] SearchBar не содержит hardcoded hex-цветов; палитра — через DS tokens
|
||||||
|
- [ ] SearchBar states (loading, empty, results) используют соответствующие DS-компоненты
|
||||||
|
- [ ] Логика SearchBar не изменена — все тесты проходят
|
||||||
|
- [ ] ESLint `no-restricted-imports` не нарушен (только `Box` из MUI напрямую)
|
||||||
|
- [ ] `npm run test:frontend && npm run lint -w apps/frontend && npm run build:frontend` проходят
|
||||||
27
docs/features/pilot-migration/tasks.md
Normal file
27
docs/features/pilot-migration/tasks.md
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Pilot Migration — Tasks
|
||||||
|
|
||||||
|
## 1. HomePage
|
||||||
|
|
||||||
|
- [x] Заменить h1 на `<Heading level={1} size="display">`
|
||||||
|
- [x] Заменить p с text-secondary на `<Text tone="secondary">`
|
||||||
|
- [x] Заменить p с #888/#aaa на `<Text tone="muted">`
|
||||||
|
- [x] Заменить div-wrapper на `<Box textAlign="center" pt={30}>`
|
||||||
|
- [x] Удалить все inline-стили
|
||||||
|
|
||||||
|
## 2. SearchBar
|
||||||
|
|
||||||
|
- [x] Заменить `<input>` на `<TextField label="Поиск" hiddenLabel>`
|
||||||
|
- [x] Заменить `<ul>` на `<Surface elevation="sm">` с positioning styles
|
||||||
|
- [x] Заменить loading text на `<Text tone="muted">`
|
||||||
|
- [x] Заменить empty text на `<Text tone="muted">`
|
||||||
|
- [x] Заменить `<li>` results на `<Box>` с flex layout
|
||||||
|
- [x] Заменить hardcoded hex на theme tokens
|
||||||
|
- [x] Заменить JS hover handlers на MUI `sx={{ '&:hover': { bgcolor: 'action.hover' } }}`
|
||||||
|
- [x] Заменить тип "Акция"/"Облигация" на `<Chip>`
|
||||||
|
|
||||||
|
## 3. Verification
|
||||||
|
|
||||||
|
- [x] `npm run test:frontend` — 23 suites, 111 passed
|
||||||
|
- [x] `npm run lint -w apps/frontend` — чист, 0 warnings
|
||||||
|
- [x] `npm run build:frontend` — проходит
|
||||||
|
- [x] ESLint `no-restricted-imports` не нарушен (использован `@mui/material/Box` deep import)
|
||||||
Loading…
x
Reference in New Issue
Block a user