Compare commits
6 Commits
1774c7ffd6
...
2744608d06
| Author | SHA1 | Date | |
|---|---|---|---|
| 2744608d06 | |||
| ad196164ee | |||
| d1f9128441 | |||
| 8e70691bde | |||
| 943a6aec2d | |||
| 79011c779f |
@ -7,7 +7,7 @@ module.exports = {
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin', 'react', 'react-hooks', 'import'],
|
||||
plugins: ['@typescript-eslint/eslint-plugin', 'react', 'react-hooks', 'import', '@conarti/feature-sliced'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react/recommended',
|
||||
@ -33,7 +33,18 @@ module.exports = {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
|
||||
// FSD layer boundaries
|
||||
// FSD layer boundaries (from @conarti/eslint-plugin-feature-sliced)
|
||||
// layers-slices: catches cross-layer violations (e.g., shared→entities)
|
||||
'@conarti/feature-sliced/layers-slices': ['error', {
|
||||
// allow test files and test utilities to import from any layer for mocking
|
||||
ignoreInFilesPatterns: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx', '**/test/**'],
|
||||
}],
|
||||
// absolute-relative: false positives with @/ alias convention — disabled
|
||||
'@conarti/feature-sliced/absolute-relative': 'off',
|
||||
// public-api: too strict for app/ and test internals — disabled
|
||||
'@conarti/feature-sliced/public-api': 'off',
|
||||
|
||||
// FSD layer boundaries (from import/no-restricted-paths)
|
||||
// NOTE: `from` = what's being imported, `target` = the file doing the import
|
||||
'import/no-restricted-paths': [
|
||||
'error',
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
"react-router-dom": "^6.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
|
||||
@ -1,15 +1 @@
|
||||
import { createContext } from 'react';
|
||||
import type { UserResponse } from '@/shared/api/responses';
|
||||
|
||||
export interface SessionContextValue {
|
||||
user: UserResponse | null;
|
||||
accessToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string, name?: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
updateProfile: (data: { name?: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
export const SessionContext = createContext<SessionContextValue | null>(null);
|
||||
export { SessionContext, type SessionContextValue } from '@/shared/lib/session-context';
|
||||
|
||||
@ -1,13 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { SessionContext, type SessionState } from './sessionContext';
|
||||
import { SessionContext } from './sessionContext';
|
||||
import { useSession } from './useSession';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type SessionState = {
|
||||
user: { id: number; email: string; name: string; role: string } | null;
|
||||
accessToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
register: () => Promise<void>;
|
||||
updateProfile: () => Promise<void>;
|
||||
refreshSession: () => Promise<void>;
|
||||
};
|
||||
|
||||
const mockSession: SessionState = {
|
||||
user: { id: 1, email: 'user@test.com', name: 'Test User', role: 'user' },
|
||||
accessToken: 'mock-access-token',
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
login: vi.fn().mockResolvedValue(undefined),
|
||||
logout: vi.fn(),
|
||||
register: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import { usePositionMutations } from '@/entities/portfolio';
|
||||
|
||||
export function useAddPosition(portfolioId: number) {
|
||||
const { add } = usePositionMutations(portfolioId);
|
||||
return add;
|
||||
}
|
||||
1
apps/frontend/src/features/add-position/index.ts
Normal file
1
apps/frontend/src/features/add-position/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { AddPositionForm } from './ui/AddPositionForm';
|
||||
@ -0,0 +1,30 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export function useAddPositionForm() {
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newSecid, setNewSecid] = useState('');
|
||||
const [newQty, setNewQty] = useState('1');
|
||||
const [newPrice, setNewPrice] = useState('');
|
||||
const [newDate, setNewDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
|
||||
function reset() {
|
||||
setNewSecid('');
|
||||
setNewQty('1');
|
||||
setNewPrice('');
|
||||
setNewDate(new Date().toISOString().split('T')[0]);
|
||||
}
|
||||
|
||||
return {
|
||||
showAddForm,
|
||||
setShowAddForm,
|
||||
newSecid,
|
||||
setNewSecid,
|
||||
newQty,
|
||||
setNewQty,
|
||||
newPrice,
|
||||
setNewPrice,
|
||||
newDate,
|
||||
setNewDate,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
111
apps/frontend/src/features/add-position/ui/AddPositionForm.tsx
Normal file
111
apps/frontend/src/features/add-position/ui/AddPositionForm.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { useAddPosition } from '../api/useAddPosition';
|
||||
import { useAddPositionForm } from '../model/useAddPositionForm';
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
export function AddPositionForm({ portfolioId }: { portfolioId: number }) {
|
||||
const addPosition = useAddPosition(portfolioId);
|
||||
const form = useAddPositionForm();
|
||||
|
||||
function handleAddPosition() {
|
||||
if (!form.newSecid.trim() || !parseInt(form.newQty, 10)) return;
|
||||
addPosition.mutate(
|
||||
{
|
||||
secid: form.newSecid.trim().toUpperCase(),
|
||||
quantity: parseInt(form.newQty, 10),
|
||||
buyPrice: form.newPrice ? parseFloat(form.newPrice) : undefined,
|
||||
buyDate: form.newDate || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
form.setShowAddForm(false);
|
||||
form.reset();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 16,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
alignItems: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
|
||||
Тикер
|
||||
</label>
|
||||
<input
|
||||
value={form.newSecid}
|
||||
onChange={(e) => form.setNewSecid(e.target.value)}
|
||||
placeholder="SBER"
|
||||
style={{ ...inputStyle, width: 120 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
|
||||
Количество
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.newQty}
|
||||
onChange={(e) => form.setNewQty(e.target.value)}
|
||||
style={{ ...inputStyle, width: 100 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
|
||||
Цена покупки
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.newPrice}
|
||||
onChange={(e) => form.setNewPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
style={{ ...inputStyle, width: 120 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
|
||||
Дата покупки
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.newDate}
|
||||
onChange={(e) => form.setNewDate(e.target.value)}
|
||||
style={{ ...inputStyle, width: 150 }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddPosition}
|
||||
disabled={addPosition.isPending}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,131 +1,9 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
|
||||
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
|
||||
import { useBrokerOperations } from '@/entities/broker-operation';
|
||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
||||
import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart';
|
||||
import { BrokerOperationsTable } from '@/widgets/broker-operations-table';
|
||||
import {
|
||||
formatBrokerMoney as formatMoney,
|
||||
formatBrokerPercent as formatPercent,
|
||||
pluralize,
|
||||
} from '@/shared/lib/formatters';
|
||||
|
||||
function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) {
|
||||
return (
|
||||
<section className="broker-overview__summary" aria-label="Сводка счёта">
|
||||
<div className="broker-overview__card">
|
||||
<span className="broker-overview__label">Стоимость портфеля</span>
|
||||
<strong className="broker-overview__total">
|
||||
{formatMoney(portfolio.totals.portfolio)}
|
||||
</strong>
|
||||
<span>За день: {formatMoney(portfolio.yields.daily)}</span>
|
||||
<span>Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)}</span>
|
||||
<span>Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)}</span>
|
||||
</div>
|
||||
<div className="broker-overview__card">
|
||||
<span className="broker-overview__label">Денежный остаток</span>
|
||||
{portfolio.cash.length === 0 ? (
|
||||
<span>Нет денежных остатков</span>
|
||||
) : (
|
||||
<ul className="broker-overview__cash">
|
||||
{portfolio.cash.map((money, index) => (
|
||||
<li key={`${money.currency}-${index}`}>
|
||||
<span>{money.currency}</span>
|
||||
<strong>{formatMoney(money)}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function allocationPercent(value: BrokerMoney | null, total: BrokerMoney | null) {
|
||||
if (!value || !total || total.value <= 0) return null;
|
||||
return (value.value / total.value) * 100;
|
||||
}
|
||||
|
||||
function formatAllocationPercent(value: number | null) {
|
||||
return value === null ? '—' : `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function BrokerAssetCards({
|
||||
accountId,
|
||||
portfolio,
|
||||
}: {
|
||||
accountId: string;
|
||||
portfolio: BrokerPortfolio;
|
||||
}) {
|
||||
const basePath = `/broker/${encodeURIComponent(accountId)}`;
|
||||
const cards = [
|
||||
{
|
||||
label: 'Акции',
|
||||
count: portfolio.positionCounts.shares,
|
||||
countLabel: pluralize(portfolio.positionCounts.shares, 'позиция', 'позиции', 'позиций'),
|
||||
value: portfolio.totals.shares,
|
||||
path: `${basePath}/shares`,
|
||||
},
|
||||
{
|
||||
label: 'Облигации',
|
||||
count: portfolio.positionCounts.bonds,
|
||||
countLabel: pluralize(portfolio.positionCounts.bonds, 'выпуск', 'выпуска', 'выпусков'),
|
||||
value: portfolio.totals.bonds,
|
||||
path: `${basePath}/bonds`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="broker-overview__assets" aria-label="Основные классы активов">
|
||||
{cards.map((card) => (
|
||||
<Link
|
||||
className="broker-overview__card broker-overview__asset-link"
|
||||
key={card.label}
|
||||
to={card.path}
|
||||
>
|
||||
<strong className="broker-overview__asset-title">{card.label}</strong>
|
||||
<span>
|
||||
{card.count} {card.countLabel}
|
||||
</span>
|
||||
<span>{formatMoney(card.value)}</span>
|
||||
<span>
|
||||
{formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BrokerOverviewSkeleton() {
|
||||
return (
|
||||
<div className="broker-overview" aria-label="Загрузка сводки счёта">
|
||||
<div className="broker-overview__summary">
|
||||
{[1, 2].map((item) => (
|
||||
<div className="broker-overview__card" key={item}>
|
||||
<SkeletonBlock height={16} width="45%" />
|
||||
<SkeletonBlock height={28} width="70%" />
|
||||
<SkeletonBlock height={16} width="55%" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="broker-allocation">
|
||||
<SkeletonBlock height={160} width={160} borderRadius={80} />
|
||||
<SkeletonBlock height={80} width="60%" />
|
||||
</div>
|
||||
<div className="broker-overview__assets">
|
||||
{[1, 2].map((item) => (
|
||||
<div className="broker-overview__card" key={item}>
|
||||
<SkeletonBlock height={20} width="35%" />
|
||||
<SkeletonBlock height={16} width="55%" />
|
||||
<SkeletonBlock height={16} width="70%" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton } from '@/widgets/broker-overview';
|
||||
|
||||
export function BrokerAccountOverviewPage() {
|
||||
const { accountId, portfolio } = useBrokerAccountContext();
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
BROKER_OPERATION_TYPE_OPTIONS,
|
||||
@ -7,43 +7,30 @@ import {
|
||||
} from '@/entities/broker-operation';
|
||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
||||
import { BrokerOperationsTable } from '@/widgets/broker-operations-table';
|
||||
import { useCursorPagination } from '@/shared/lib/useCursorPagination';
|
||||
|
||||
export function BrokerOperationsPage() {
|
||||
const { accountId } = useBrokerAccountContext();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const urlType = searchParams.get('type');
|
||||
const selectedType = isBrokerOperationType(urlType) ? urlType : '';
|
||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
|
||||
const pagination = useCursorPagination();
|
||||
|
||||
const operations = useBrokerOperations(accountId, {
|
||||
limit: 10,
|
||||
cursor,
|
||||
cursor: pagination.cursor,
|
||||
operationTypes: selectedType || undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCursor(undefined);
|
||||
setCursorStack([]);
|
||||
}, [selectedType]);
|
||||
pagination.reset();
|
||||
}, [selectedType]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function handleTypeChange(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const nextType = event.target.value;
|
||||
setSearchParams(nextType ? { type: nextType } : {}, { replace: true });
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
const nextCursor = operations.data?.nextCursor;
|
||||
if (!nextCursor || !operations.data?.hasNext) return;
|
||||
setCursorStack((previous) => [...previous, cursor]);
|
||||
setCursor(nextCursor);
|
||||
}
|
||||
|
||||
function handlePrevious() {
|
||||
if (cursorStack.length === 0) return;
|
||||
setCursor(cursorStack[cursorStack.length - 1]);
|
||||
setCursorStack((previous) => previous.slice(0, -1));
|
||||
}
|
||||
|
||||
const history = operations.error ? (
|
||||
<p role="alert">Не удалось загрузить историю операций</p>
|
||||
) : (
|
||||
@ -56,11 +43,11 @@ export function BrokerOperationsPage() {
|
||||
isFetching={operations.isFetching}
|
||||
page={operations.data}
|
||||
pagination={{
|
||||
pageNumber: cursorStack.length + 1,
|
||||
canGoBack: cursorStack.length > 0,
|
||||
pageNumber: pagination.pageNumber,
|
||||
canGoBack: pagination.pageNumber > 1,
|
||||
canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor),
|
||||
onPrevious: handlePrevious,
|
||||
onNext: handleNext,
|
||||
onPrevious: pagination.handlePrevious,
|
||||
onNext: () => pagination.handleNext(operations.data?.nextCursor),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,262 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type {
|
||||
BrokerPosition,
|
||||
BrokerPositionsPage as BrokerPositionsPageData,
|
||||
} from '@/shared/api/responses';
|
||||
import { TableSkeleton } from '@/shared/ui/TableSkeleton';
|
||||
import { getBrokerInstrumentPath, useBrokerPositions } from '@/entities/broker-position';
|
||||
import { useBrokerPositions } from '@/entities/broker-position';
|
||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
||||
import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters';
|
||||
|
||||
const tableStyle = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
fontSize: 14,
|
||||
} satisfies React.CSSProperties;
|
||||
|
||||
const thStyle = {
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontWeight: 600,
|
||||
padding: '10px 8px',
|
||||
} satisfies React.CSSProperties;
|
||||
|
||||
const tdStyle = {
|
||||
borderBottom: '1px solid #eeeeee',
|
||||
padding: '10px 8px',
|
||||
verticalAlign: 'top',
|
||||
} satisfies React.CSSProperties;
|
||||
|
||||
const pagButtonStyle = {
|
||||
padding: '6px 14px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #e0e0e0',
|
||||
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) {
|
||||
return value == null ? '-' : value.toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
function PositionTicker({ position }: { position: BrokerPosition }) {
|
||||
const label = position.ticker || position.figi || '-';
|
||||
const path = getBrokerInstrumentPath({
|
||||
ticker: position.ticker,
|
||||
instrumentType: position.instrumentType,
|
||||
classCode: position.classCode,
|
||||
});
|
||||
|
||||
if (!path || label === '-') {
|
||||
return <strong>{label}</strong>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={path} style={{ fontWeight: 700 }}>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function BrokerPositionTable({
|
||||
title,
|
||||
page,
|
||||
isLoading,
|
||||
isFetching,
|
||||
emptyMessage,
|
||||
pageNumber,
|
||||
onNext,
|
||||
onPrevious,
|
||||
}: {
|
||||
title: string;
|
||||
page: BrokerPositionsPageData | undefined;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
emptyMessage: string;
|
||||
pageNumber: number;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
}) {
|
||||
const positions = page?.items ?? [];
|
||||
const canGoBack = pageNumber > 1;
|
||||
const canGoForward = Boolean(page?.hasNext && page.nextCursor);
|
||||
|
||||
return (
|
||||
<section aria-labelledby={`broker-${title.toLowerCase()}-heading`}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<h2 id={`broker-${title.toLowerCase()}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
||||
{title}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Предыдущая страница"
|
||||
onClick={onPrevious}
|
||||
disabled={!canGoBack || isFetching}
|
||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? (
|
||||
<span
|
||||
className="loading-spinner"
|
||||
style={{ width: 14, height: 14, display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
'←'
|
||||
)}
|
||||
</button>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 20,
|
||||
textAlign: 'center',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{pageNumber}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Следующая страница"
|
||||
onClick={onNext}
|
||||
disabled={!canGoForward || isFetching}
|
||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? (
|
||||
<span
|
||||
className="loading-spinner"
|
||||
style={{ width: 14, height: 14, display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
'→'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
<table style={tableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" style={thStyle}>
|
||||
Тикер
|
||||
</th>
|
||||
<th align="left" style={thStyle}>
|
||||
Название
|
||||
</th>
|
||||
<th align="right" style={thStyle}>
|
||||
Количество
|
||||
</th>
|
||||
<th align="right" style={thStyle}>
|
||||
Цена
|
||||
</th>
|
||||
<th align="right" style={thStyle}>
|
||||
Стоимость
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<TableSkeleton
|
||||
rows={4}
|
||||
columns={[
|
||||
{ width: '30%' },
|
||||
{ width: '50%' },
|
||||
{ width: '20%' },
|
||||
{ width: '25%' },
|
||||
{ width: '25%' },
|
||||
]}
|
||||
/>
|
||||
</table>
|
||||
</div>
|
||||
) : positions.length === 0 && !isFetching ? (
|
||||
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
<table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" style={thStyle}>
|
||||
Тикер
|
||||
</th>
|
||||
<th align="left" style={thStyle}>
|
||||
Название
|
||||
</th>
|
||||
<th align="right" style={thStyle}>
|
||||
Количество
|
||||
</th>
|
||||
<th align="right" style={thStyle}>
|
||||
Цена
|
||||
</th>
|
||||
<th align="right" style={thStyle}>
|
||||
Стоимость
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.map((position) => (
|
||||
<tr
|
||||
key={
|
||||
position.positionUid ||
|
||||
position.instrumentUid ||
|
||||
position.ticker ||
|
||||
position.figi
|
||||
}
|
||||
>
|
||||
<td style={tdStyle}>
|
||||
<PositionTicker position={position} />
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{position.name || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td align="right" style={tdStyle}>
|
||||
{formatQuantity(position.quantity)}
|
||||
</td>
|
||||
<td align="right" style={tdStyle}>
|
||||
{formatMoney(position.currentPrice)}
|
||||
</td>
|
||||
<td align="right" style={tdStyle}>
|
||||
{formatMoney(position.currentValue)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{isFetching && (
|
||||
<div className="table-loading-overlay">
|
||||
<div className="loading-spinner" />
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
Загрузка страницы {pageNumber}…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
import { BrokerPositionTable } from '@/widgets/broker-positions-table';
|
||||
import { useCursorPagination } from '@/shared/lib/useCursorPagination';
|
||||
|
||||
type BrokerPositionsPageProps = {
|
||||
type: 'share' | 'bond';
|
||||
@ -265,22 +10,8 @@ type BrokerPositionsPageProps = {
|
||||
|
||||
export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
|
||||
const { accountId } = useBrokerAccountContext();
|
||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
|
||||
const positions = useBrokerPositions(accountId, { type, limit: 10, cursor });
|
||||
|
||||
function handleNext() {
|
||||
const nextCursor = positions.data?.nextCursor;
|
||||
if (!nextCursor || !positions.data?.hasNext) return;
|
||||
setCursorStack((previous) => [...previous, cursor]);
|
||||
setCursor(nextCursor);
|
||||
}
|
||||
|
||||
function handlePrevious() {
|
||||
if (cursorStack.length === 0) return;
|
||||
setCursor(cursorStack[cursorStack.length - 1]);
|
||||
setCursorStack((previous) => previous.slice(0, -1));
|
||||
}
|
||||
const pagination = useCursorPagination();
|
||||
const positions = useBrokerPositions(accountId, { type, limit: 10, cursor: pagination.cursor });
|
||||
|
||||
if (positions.error) {
|
||||
return (
|
||||
@ -302,9 +33,9 @@ export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
|
||||
isLoading={positions.isLoading}
|
||||
isFetching={positions.isFetching}
|
||||
emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'}
|
||||
pageNumber={cursorStack.length + 1}
|
||||
onNext={handleNext}
|
||||
onPrevious={handlePrevious}
|
||||
pageNumber={pagination.pageNumber}
|
||||
onNext={() => pagination.handleNext(positions.data?.nextCursor)}
|
||||
onPrevious={pagination.handlePrevious}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import { PortfolioSummary } from '@/widgets/portfolio-summary';
|
||||
import { AnalyticsSummary } from '@/widgets/portfolio-analytics';
|
||||
import { SharePositionTable } from '@/widgets/share-positions-table';
|
||||
import { BondPositionTable } from '@/widgets/bond-positions-table';
|
||||
import { AddPositionForm } from '@/features/add-position';
|
||||
|
||||
export function PortfolioDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@ -13,18 +14,10 @@ export function PortfolioDetailPage() {
|
||||
|
||||
const { data: portfolio, isLoading, error } = usePortfolio(portfolioId);
|
||||
const { update, remove } = usePortfolioMutations();
|
||||
const {
|
||||
update: updatePosition,
|
||||
remove: removePosition,
|
||||
add: addPosition,
|
||||
} = usePositionMutations(portfolioId);
|
||||
const { update: updatePosition, remove: removePosition } = usePositionMutations(portfolioId);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newSecid, setNewSecid] = useState('');
|
||||
const [newQty, setNewQty] = useState('1');
|
||||
const [newPrice, setNewPrice] = useState('');
|
||||
const [newDate, setNewDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@ -48,26 +41,6 @@ export function PortfolioDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddPosition() {
|
||||
if (!newSecid.trim() || !parseInt(newQty, 10)) return;
|
||||
addPosition.mutate(
|
||||
{
|
||||
secid: newSecid.trim().toUpperCase(),
|
||||
quantity: parseInt(newQty, 10),
|
||||
buyPrice: newPrice ? parseFloat(newPrice) : undefined,
|
||||
buyDate: newDate || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowAddForm(false);
|
||||
setNewSecid('');
|
||||
setNewQty('1');
|
||||
setNewPrice('');
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
@ -167,108 +140,7 @@ export function PortfolioDetailPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 16,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
alignItems: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
|
||||
Тикер
|
||||
</label>
|
||||
<input
|
||||
value={newSecid}
|
||||
onChange={(e) => setNewSecid(e.target.value)}
|
||||
placeholder="SBER"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
width: 120,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
|
||||
Количество
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={newQty}
|
||||
onChange={(e) => setNewQty(e.target.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
width: 100,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
|
||||
Цена покупки
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newPrice}
|
||||
onChange={(e) => setNewPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
width: 120,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
|
||||
Дата покупки
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newDate}
|
||||
onChange={(e) => setNewDate(e.target.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
width: 150,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddPosition}
|
||||
disabled={addPosition.isPending}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showAddForm && <AddPositionForm portfolioId={portfolioId} />}
|
||||
|
||||
<SharePositionTable
|
||||
positions={portfolio.positions.filter((p) => p.type === 'share')}
|
||||
|
||||
15
apps/frontend/src/shared/lib/session-context.ts
Normal file
15
apps/frontend/src/shared/lib/session-context.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { createContext } from 'react';
|
||||
import type { UserResponse } from '@/shared/api/responses';
|
||||
|
||||
export interface SessionContextValue {
|
||||
user: UserResponse | null;
|
||||
accessToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string, name?: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
updateProfile: (data: { name?: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
export const SessionContext = createContext<SessionContextValue | null>(null);
|
||||
68
apps/frontend/src/shared/lib/test/TestSessionProvider.tsx
Normal file
68
apps/frontend/src/shared/lib/test/TestSessionProvider.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { type ReactNode, useEffect, useState, useCallback } from 'react';
|
||||
import { SessionContext, type SessionContextValue } from '@/shared/lib/session-context';
|
||||
import * as sessionApi from '@/entities/session/api/sessionApi';
|
||||
|
||||
export function TestSessionProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<SessionContextValue['user']>(null);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
sessionApi
|
||||
.refresh()
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setUser(result.user);
|
||||
setAccessToken(result.accessToken);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const result = await sessionApi.login(email, password);
|
||||
setUser(result.user);
|
||||
setAccessToken(result.accessToken);
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (email: string, password: string, name?: string) => {
|
||||
const result = await sessionApi.register(email, password, name);
|
||||
setUser(result.user);
|
||||
setAccessToken(result.accessToken);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await sessionApi.logout();
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
}, []);
|
||||
|
||||
const updateProfile = useCallback(async (data: { name?: string }) => {
|
||||
const updated = await sessionApi.updateProfile(data);
|
||||
setUser(updated);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Загрузка...</div>;
|
||||
}
|
||||
|
||||
const value: SessionContextValue = {
|
||||
user,
|
||||
accessToken,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
updateProfile,
|
||||
};
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { type ReactElement } from 'react';
|
||||
import { render, type RenderOptions } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { SessionProvider } from '@/app/providers';
|
||||
import { TestSessionProvider } from './TestSessionProvider';
|
||||
|
||||
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||
queryClient?: QueryClient;
|
||||
@ -33,7 +33,7 @@ export function renderWithProviders(
|
||||
initialEntries={[route]}
|
||||
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
|
||||
>
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
<TestSessionProvider>{children}</TestSessionProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
40
apps/frontend/src/shared/lib/useCursorPagination.ts
Normal file
40
apps/frontend/src/shared/lib/useCursorPagination.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export function useCursorPagination() {
|
||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
|
||||
|
||||
const handleNext = useCallback(
|
||||
(nextCursor: string | null | undefined) => {
|
||||
if (!nextCursor) return;
|
||||
setCursorStack((prev) => [...prev, cursor]);
|
||||
setCursor(nextCursor);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
},
|
||||
[cursor],
|
||||
);
|
||||
|
||||
const handlePrevious = useCallback(() => {
|
||||
setCursorStack((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
const lastCursor = prev[prev.length - 1];
|
||||
const remaining = prev.slice(0, -1);
|
||||
setCursor(lastCursor);
|
||||
return remaining;
|
||||
});
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setCursor(undefined);
|
||||
setCursorStack([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
cursor,
|
||||
pageNumber: cursorStack.length + 1,
|
||||
handleNext,
|
||||
handlePrevious,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
3
apps/frontend/src/widgets/broker-overview/index.ts
Normal file
3
apps/frontend/src/widgets/broker-overview/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { BrokerSummary } from './ui/BrokerSummary';
|
||||
export { BrokerAssetCards } from './ui/BrokerAssetCards';
|
||||
export { BrokerOverviewSkeleton } from './ui/BrokerOverviewSkeleton';
|
||||
@ -0,0 +1,59 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
|
||||
import { formatBrokerMoney, pluralize } from '@/shared/lib/formatters';
|
||||
|
||||
function allocationPercent(value: BrokerMoney | null, total: BrokerMoney | null) {
|
||||
if (!value || !total || total.value <= 0) return null;
|
||||
return (value.value / total.value) * 100;
|
||||
}
|
||||
|
||||
function formatAllocationPercent(value: number | null) {
|
||||
return value === null ? '\u2014' : `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function BrokerAssetCards({
|
||||
accountId,
|
||||
portfolio,
|
||||
}: {
|
||||
accountId: string;
|
||||
portfolio: BrokerPortfolio;
|
||||
}) {
|
||||
const basePath = `/broker/${encodeURIComponent(accountId)}`;
|
||||
const cards = [
|
||||
{
|
||||
label: 'Акции',
|
||||
count: portfolio.positionCounts.shares,
|
||||
countLabel: pluralize(portfolio.positionCounts.shares, 'позиция', 'позиции', 'позиций'),
|
||||
value: portfolio.totals.shares,
|
||||
path: `${basePath}/shares`,
|
||||
},
|
||||
{
|
||||
label: 'Облигации',
|
||||
count: portfolio.positionCounts.bonds,
|
||||
countLabel: pluralize(portfolio.positionCounts.bonds, 'выпуск', 'выпуска', 'выпусков'),
|
||||
value: portfolio.totals.bonds,
|
||||
path: `${basePath}/bonds`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="broker-overview__assets" aria-label="Основные классы активов">
|
||||
{cards.map((card) => (
|
||||
<Link
|
||||
className="broker-overview__card broker-overview__asset-link"
|
||||
key={card.label}
|
||||
to={card.path}
|
||||
>
|
||||
<strong className="broker-overview__asset-title">{card.label}</strong>
|
||||
<span>
|
||||
{card.count} {card.countLabel}
|
||||
</span>
|
||||
<span>{formatBrokerMoney(card.value)}</span>
|
||||
<span>
|
||||
{formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
|
||||
|
||||
export function BrokerOverviewSkeleton() {
|
||||
return (
|
||||
<div className="broker-overview" aria-label="Загрузка сводки счёта">
|
||||
<div className="broker-overview__summary">
|
||||
{[1, 2].map((item) => (
|
||||
<div className="broker-overview__card" key={item}>
|
||||
<SkeletonBlock height={16} width="45%" />
|
||||
<SkeletonBlock height={28} width="70%" />
|
||||
<SkeletonBlock height={16} width="55%" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="broker-allocation">
|
||||
<SkeletonBlock height={160} width={160} borderRadius={80} />
|
||||
<SkeletonBlock height={80} width="60%" />
|
||||
</div>
|
||||
<div className="broker-overview__assets">
|
||||
{[1, 2].map((item) => (
|
||||
<div className="broker-overview__card" key={item}>
|
||||
<SkeletonBlock height={20} width="35%" />
|
||||
<SkeletonBlock height={16} width="55%" />
|
||||
<SkeletonBlock height={16} width="70%" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
import type { BrokerPortfolio } from '@/shared/api/responses';
|
||||
import {
|
||||
formatBrokerMoney as formatMoney,
|
||||
formatBrokerPercent as formatPercent,
|
||||
} from '@/shared/lib/formatters';
|
||||
|
||||
export function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) {
|
||||
return (
|
||||
<section className="broker-overview__summary" aria-label="Сводка счёта">
|
||||
<div className="broker-overview__card">
|
||||
<span className="broker-overview__label">Стоимость портфеля</span>
|
||||
<strong className="broker-overview__total">
|
||||
{formatMoney(portfolio.totals.portfolio)}
|
||||
</strong>
|
||||
<span>За день: {formatMoney(portfolio.yields.daily)}</span>
|
||||
<span>Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)}</span>
|
||||
<span>Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)}</span>
|
||||
</div>
|
||||
<div className="broker-overview__card">
|
||||
<span className="broker-overview__label">Денежный остаток</span>
|
||||
{portfolio.cash.length === 0 ? (
|
||||
<span>Нет денежных остатков</span>
|
||||
) : (
|
||||
<ul className="broker-overview__cash">
|
||||
{portfolio.cash.map((money, index) => (
|
||||
<li key={`${money.currency}-${index}`}>
|
||||
<span>{money.currency}</span>
|
||||
<strong>{formatMoney(money)}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { BrokerPositionTable } from './ui/BrokerPositionTable';
|
||||
@ -0,0 +1,234 @@
|
||||
import type { BrokerPositionsPage as BrokerPositionsPageData } from '@/shared/api/responses';
|
||||
import { TableSkeleton } from '@/shared/ui/TableSkeleton';
|
||||
import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters';
|
||||
import { PositionTicker } from './PositionTicker';
|
||||
|
||||
const tableStyle = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
fontSize: 14,
|
||||
} satisfies React.CSSProperties;
|
||||
|
||||
const thStyle = {
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontWeight: 600,
|
||||
padding: '10px 8px',
|
||||
} satisfies React.CSSProperties;
|
||||
|
||||
const tdStyle = {
|
||||
borderBottom: '1px solid #eeeeee',
|
||||
padding: '10px 8px',
|
||||
verticalAlign: 'top',
|
||||
} satisfies React.CSSProperties;
|
||||
|
||||
const pagButtonStyle = {
|
||||
padding: '6px 14px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #e0e0e0',
|
||||
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) {
|
||||
return value == null ? '-' : value.toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
export function BrokerPositionTable({
|
||||
title,
|
||||
page,
|
||||
isLoading,
|
||||
isFetching,
|
||||
emptyMessage,
|
||||
pageNumber,
|
||||
onNext,
|
||||
onPrevious,
|
||||
}: {
|
||||
title: string;
|
||||
page: BrokerPositionsPageData | undefined;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
emptyMessage: string;
|
||||
pageNumber: number;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
}) {
|
||||
const positions = page?.items ?? [];
|
||||
const canGoBack = pageNumber > 1;
|
||||
const canGoForward = Boolean(page?.hasNext && page.nextCursor);
|
||||
|
||||
return (
|
||||
<section aria-labelledby={`broker-${title.toLowerCase()}-heading`}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<h2 id={`broker-${title.toLowerCase()}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
||||
{title}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Предыдущая страница"
|
||||
onClick={onPrevious}
|
||||
disabled={!canGoBack || isFetching}
|
||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? (
|
||||
<span
|
||||
className="loading-spinner"
|
||||
style={{ width: 14, height: 14, display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
'←'
|
||||
)}
|
||||
</button>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 20,
|
||||
textAlign: 'center',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{pageNumber}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Следующая страница"
|
||||
onClick={onNext}
|
||||
disabled={!canGoForward || isFetching}
|
||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? (
|
||||
<span
|
||||
className="loading-spinner"
|
||||
style={{ width: 14, height: 14, display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
'→'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
<table style={tableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" style={thStyle}>
|
||||
Тикер
|
||||
</th>
|
||||
<th align="left" style={thStyle}>
|
||||
Название
|
||||
</th>
|
||||
<th align="right" style={thStyle}>
|
||||
Количество
|
||||
</th>
|
||||
<th align="right" style={thStyle}>
|
||||
Цена
|
||||
</th>
|
||||
<th align="right" style={thStyle}>
|
||||
Стоимость
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<TableSkeleton
|
||||
rows={4}
|
||||
columns={[
|
||||
{ width: '30%' },
|
||||
{ width: '50%' },
|
||||
{ width: '20%' },
|
||||
{ width: '25%' },
|
||||
{ width: '25%' },
|
||||
]}
|
||||
/>
|
||||
</table>
|
||||
</div>
|
||||
) : positions.length === 0 && !isFetching ? (
|
||||
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
<table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" style={thStyle}>
|
||||
Тикер
|
||||
</th>
|
||||
<th align="left" style={thStyle}>
|
||||
Название
|
||||
</th>
|
||||
<th align="right" style={thStyle}>
|
||||
Количество
|
||||
</th>
|
||||
<th align="right" style={thStyle}>
|
||||
Цена
|
||||
</th>
|
||||
<th align="right" style={thStyle}>
|
||||
Стоимость
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.map((position) => (
|
||||
<tr
|
||||
key={
|
||||
position.positionUid ||
|
||||
position.instrumentUid ||
|
||||
position.ticker ||
|
||||
position.figi
|
||||
}
|
||||
>
|
||||
<td style={tdStyle}>
|
||||
<PositionTicker position={position} />
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{position.name || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td align="right" style={tdStyle}>
|
||||
{formatQuantity(position.quantity)}
|
||||
</td>
|
||||
<td align="right" style={tdStyle}>
|
||||
{formatMoney(position.currentPrice)}
|
||||
</td>
|
||||
<td align="right" style={tdStyle}>
|
||||
{formatMoney(position.currentValue)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{isFetching && (
|
||||
<div className="table-loading-overlay">
|
||||
<div className="loading-spinner" />
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
Загрузка страницы {pageNumber}…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { BrokerPosition } from '@/shared/api/responses';
|
||||
import { getBrokerInstrumentPath } from '@/entities/broker-position';
|
||||
|
||||
export function PositionTicker({ position }: { position: BrokerPosition }) {
|
||||
const label = position.ticker || position.figi || '-';
|
||||
const path = getBrokerInstrumentPath({
|
||||
ticker: position.ticker,
|
||||
instrumentType: position.instrumentType,
|
||||
classCode: position.classCode,
|
||||
});
|
||||
|
||||
if (!path || label === '-') {
|
||||
return <strong>{label}</strong>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={path} style={{ fontWeight: 700 }}>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
1310
docs/features/fsd-frontend-refactor/plan.md
Normal file
1310
docs/features/fsd-frontend-refactor/plan.md
Normal file
File diff suppressed because it is too large
Load Diff
96
docs/features/fsd-frontend-refactor/spec.md
Normal file
96
docs/features/fsd-frontend-refactor/spec.md
Normal file
@ -0,0 +1,96 @@
|
||||
# FSD Frontend Refactor
|
||||
|
||||
## Цель
|
||||
|
||||
Довести фронтенд-архитектуру до эталонного Feature-Sliced Design (FSD) — устранить существующие нарушения, вынести жирные страницы в виджеты/фичи, внедрить автоматический контроль FSD-правил.
|
||||
|
||||
Текущее состояние: 4.2/5 по FSD-зрелости. Цель: 5/5.
|
||||
|
||||
## Задачи
|
||||
|
||||
### 1. Исправить нарушение слоёв в shared/lib/test
|
||||
|
||||
- `shared/lib/test/test-utils.tsx` импортирует `SessionProvider` из `@/app/providers`
|
||||
- Это единственное нарушение FSD (shared → app)
|
||||
- Решение: вынести `TestSessionProvider` в `shared/lib/test/` и переключить `test-utils.tsx` на него
|
||||
- Тесты не должны сломаться: `TestSessionProvider` предоставляет те же значения контекста (замоканые)
|
||||
|
||||
### 2. Вынести BrokerPositionTable из страницы в виджет
|
||||
|
||||
- `pages/broker-positions/ui/BrokerPositionsPage.tsx` (310 строк) содержит внутренние компоненты:
|
||||
- `BrokerPositionTable` (~190 строк) — таблица с пагинацией, skeleton, inline-стилями
|
||||
- `PositionTicker` (~20 строк) — ссылка на инструмент
|
||||
- `formatQuantity` — хелпер
|
||||
- Решение: создать `widgets/broker-positions-table/` с:
|
||||
- `ui/BrokerPositionTable.tsx`
|
||||
- `ui/PositionTicker.tsx`
|
||||
- `index.ts`
|
||||
- Страница сокращается до ~100 строк (только cursor-логика + композиция)
|
||||
|
||||
### 3. Вынести BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton в виджет
|
||||
|
||||
- `pages/broker-account/ui/BrokerAccountOverviewPage.tsx` (160 строк) содержит:
|
||||
- `BrokerSummary` — сводка портфеля (карточки стоимости и денег)
|
||||
- `BrokerAssetCards` — карточки классов активов (акции/облигации)
|
||||
- `BrokerOverviewSkeleton` — скелетон загрузки
|
||||
- `allocationPercent`, `formatAllocationPercent` — хелперы
|
||||
- Решение: создать `widgets/broker-overview/` с:
|
||||
- `ui/BrokerSummary.tsx`
|
||||
- `ui/BrokerAssetCards.tsx`
|
||||
- `ui/BrokerOverviewSkeleton.tsx`
|
||||
- `index.ts`
|
||||
- Страница сокращается до ~40 строк (только loading/error guard + композиция)
|
||||
|
||||
### 4. Вынести AddPositionForm в фичу
|
||||
|
||||
- `pages/portfolios/ui/PortfolioDetailPage.tsx` (290 строк) содержит inline-форму добавления позиции:
|
||||
- 4 state-переменные (newSecid, newQty, newPrice, newDate)
|
||||
- 4 input-поля с inline-стилями
|
||||
- Валидация и submit
|
||||
- Это полноценная бизнес-фича, не место в странице
|
||||
- Решение: создать `features/add-position/` с:
|
||||
- `api/useAddPosition.ts` — мутация (обёртка над usePositionMutations)
|
||||
- `model/useAddPositionForm.ts` — управление формой
|
||||
- `ui/AddPositionForm.tsx` — UI формы
|
||||
- `index.ts`
|
||||
- Страница сокращается до ~140 строк (showAddForm toggle + `<AddPositionForm portfolioId={id} />`)
|
||||
|
||||
### 5. Вынести cursor-пагинацию в shared-хук
|
||||
|
||||
- 25 строк cursor-логики дублируются в:
|
||||
- `pages/broker-positions/ui/BrokerPositionsPage.tsx`
|
||||
- `pages/broker-operations/ui/BrokerOperationsPage.tsx`
|
||||
- Решение: создать `shared/lib/useCursorPagination.ts`
|
||||
|
||||
```ts
|
||||
function useCursorPagination() {
|
||||
// cursor, pageNumber, handleNext(cursor), handlePrevious, reset
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Добавить ESLint-плагин FSD
|
||||
|
||||
- Текущий `import/no-restricted-paths` проверяет только межслойные границы
|
||||
- Плагин `@conarti/eslint-plugin-feature-sliced` добавит:
|
||||
- Проверки public API (запрет импорта из внутренних модулей в обход index.ts)
|
||||
- Проверки сегментов (api/ui/model не импортируют друг друга напрямую)
|
||||
- Решение: установить плагин и включить recommended rules
|
||||
|
||||
## Критерии приёмки (Acceptance Criteria)
|
||||
|
||||
- [ ] AC1: `test-utils.tsx` не импортирует из `@/app/*`
|
||||
- [ ] AC2: `widgets/broker-positions-table` создан, страница использует его
|
||||
- [ ] AC3: `widgets/broker-overview` создан, страница использует его
|
||||
- [ ] AC4: `features/add-position` создана, страница использует её
|
||||
- [ ] AC5: `shared/lib/useCursorPagination.ts` создан, обе страницы используют его
|
||||
- [ ] AC6: `@conarti/eslint-plugin-feature-sliced` установлен и настроен
|
||||
- [ ] AC7: `npm run lint` проходит без ошибок
|
||||
- [ ] AC8: `npm run build` проходит без ошибок
|
||||
- [ ] AC9: Все существующие тесты проходят (включая тесты рефакторимых компонентов)
|
||||
- [ ] AC10: Визуально поведение страниц не изменилось
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Не менять API-контракты компонентов (пропсы, типы). Только перемещение кода.
|
||||
- Не добавлять новую функциональность — только рефакторинг.
|
||||
- inline-стили переносятся вместе с компонентами; централизация UI-кита — отдельная задача.
|
||||
13
docs/features/fsd-frontend-refactor/tasks.md
Normal file
13
docs/features/fsd-frontend-refactor/tasks.md
Normal file
@ -0,0 +1,13 @@
|
||||
# FSD Frontend Refactor — Tasks
|
||||
|
||||
**Ветка:** `codex/fsd-frontend-refactor`
|
||||
|
||||
**Порядок:** последовательно, каждый task — независимый коммит.
|
||||
|
||||
- [ ] **Task 1:** Исправить нарушение shared → app — TestSessionProvider
|
||||
- [ ] **Task 2:** Вынести BrokerPositionTable из страницы в widgets
|
||||
- [ ] **Task 3:** Вынести BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton в widgets
|
||||
- [ ] **Task 4:** Вынести AddPositionForm в features/add-position
|
||||
- [ ] **Task 5:** Вынести useCursorPagination в shared/lib/
|
||||
- [ ] **Task 6:** Добавить @conarti/eslint-plugin-feature-sliced
|
||||
- [ ] **Финальная проверка:** `npm run build -w apps/frontend && npm run lint -w apps/frontend && npm run -w apps/frontend test run`
|
||||
31
package-lock.json
generated
31
package-lock.json
generated
@ -93,6 +93,7 @@
|
||||
"react-router-dom": "^6.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@ -3416,6 +3417,36 @@
|
||||
"node": ">=0.1.90"
|
||||
}
|
||||
},
|
||||
"node_modules/@conarti/eslint-plugin-feature-sliced": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@conarti/eslint-plugin-feature-sliced/-/eslint-plugin-feature-sliced-1.0.5.tgz",
|
||||
"integrity": "sha512-Gx90Zupi8nv6cCP3O6cYWE9GT4RIw0D9JBHWeUwXEv7qmjCd3dXSqDEmQchN7k41hhCPupZvMevH4JrSORcVjQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.17.0 || ^16.0.0 || >= 18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=7",
|
||||
"eslint-plugin-import": ">=2.26"
|
||||
}
|
||||
},
|
||||
"node_modules/@conarti/eslint-plugin-feature-sliced/node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/cascade-layer-name-parser": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.5.tgz",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user