Compare commits
6 Commits
1774c7ffd6
...
2744608d06
| Author | SHA1 | Date | |
|---|---|---|---|
| 2744608d06 | |||
| ad196164ee | |||
| d1f9128441 | |||
| 8e70691bde | |||
| 943a6aec2d | |||
| 79011c779f |
@ -7,7 +7,7 @@ module.exports = {
|
|||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
ecmaFeatures: { jsx: true },
|
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: [
|
extends: [
|
||||||
'plugin:@typescript-eslint/recommended',
|
'plugin:@typescript-eslint/recommended',
|
||||||
'plugin:react/recommended',
|
'plugin:react/recommended',
|
||||||
@ -33,7 +33,18 @@ module.exports = {
|
|||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'react/react-in-jsx-scope': '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
|
// NOTE: `from` = what's being imported, `target` = the file doing the import
|
||||||
'import/no-restricted-paths': [
|
'import/no-restricted-paths': [
|
||||||
'error',
|
'error',
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
"react-router-dom": "^6.20.0"
|
"react-router-dom": "^6.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
|||||||
@ -1,15 +1 @@
|
|||||||
import { createContext } from 'react';
|
export { SessionContext, type SessionContextValue } from '@/shared/lib/session-context';
|
||||||
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);
|
|
||||||
|
|||||||
@ -1,13 +1,26 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { renderHook } from '@testing-library/react';
|
import { renderHook } from '@testing-library/react';
|
||||||
import { SessionContext, type SessionState } from './sessionContext';
|
import { SessionContext } from './sessionContext';
|
||||||
import { useSession } from './useSession';
|
import { useSession } from './useSession';
|
||||||
import type { ReactNode } from 'react';
|
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 = {
|
const mockSession: SessionState = {
|
||||||
user: { id: 1, email: 'user@test.com', name: 'Test User', role: 'user' },
|
user: { id: 1, email: 'user@test.com', name: 'Test User', role: 'user' },
|
||||||
accessToken: 'mock-access-token',
|
accessToken: 'mock-access-token',
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
login: vi.fn().mockResolvedValue(undefined),
|
login: vi.fn().mockResolvedValue(undefined),
|
||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
register: vi.fn().mockResolvedValue(undefined),
|
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 { 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 { 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';
|
||||||
import { BrokerOperationsTable } from '@/widgets/broker-operations-table';
|
import { BrokerOperationsTable } from '@/widgets/broker-operations-table';
|
||||||
import {
|
import { BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton } from '@/widgets/broker-overview';
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BrokerAccountOverviewPage() {
|
export function BrokerAccountOverviewPage() {
|
||||||
const { accountId, portfolio } = useBrokerAccountContext();
|
const { accountId, portfolio } = useBrokerAccountContext();
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
BROKER_OPERATION_TYPE_OPTIONS,
|
BROKER_OPERATION_TYPE_OPTIONS,
|
||||||
@ -7,43 +7,30 @@ import {
|
|||||||
} from '@/entities/broker-operation';
|
} from '@/entities/broker-operation';
|
||||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
||||||
import { BrokerOperationsTable } from '@/widgets/broker-operations-table';
|
import { BrokerOperationsTable } from '@/widgets/broker-operations-table';
|
||||||
|
import { useCursorPagination } from '@/shared/lib/useCursorPagination';
|
||||||
|
|
||||||
export function BrokerOperationsPage() {
|
export function BrokerOperationsPage() {
|
||||||
const { accountId } = useBrokerAccountContext();
|
const { accountId } = useBrokerAccountContext();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const urlType = searchParams.get('type');
|
const urlType = searchParams.get('type');
|
||||||
const selectedType = isBrokerOperationType(urlType) ? urlType : '';
|
const selectedType = isBrokerOperationType(urlType) ? urlType : '';
|
||||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
const pagination = useCursorPagination();
|
||||||
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
|
|
||||||
const operations = useBrokerOperations(accountId, {
|
const operations = useBrokerOperations(accountId, {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
cursor,
|
cursor: pagination.cursor,
|
||||||
operationTypes: selectedType || undefined,
|
operationTypes: selectedType || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCursor(undefined);
|
pagination.reset();
|
||||||
setCursorStack([]);
|
}, [selectedType]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
}, [selectedType]);
|
|
||||||
|
|
||||||
function handleTypeChange(event: React.ChangeEvent<HTMLSelectElement>) {
|
function handleTypeChange(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||||
const nextType = event.target.value;
|
const nextType = event.target.value;
|
||||||
setSearchParams(nextType ? { type: nextType } : {}, { replace: true });
|
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 ? (
|
const history = operations.error ? (
|
||||||
<p role="alert">Не удалось загрузить историю операций</p>
|
<p role="alert">Не удалось загрузить историю операций</p>
|
||||||
) : (
|
) : (
|
||||||
@ -56,11 +43,11 @@ export function BrokerOperationsPage() {
|
|||||||
isFetching={operations.isFetching}
|
isFetching={operations.isFetching}
|
||||||
page={operations.data}
|
page={operations.data}
|
||||||
pagination={{
|
pagination={{
|
||||||
pageNumber: cursorStack.length + 1,
|
pageNumber: pagination.pageNumber,
|
||||||
canGoBack: cursorStack.length > 0,
|
canGoBack: pagination.pageNumber > 1,
|
||||||
canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor),
|
canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor),
|
||||||
onPrevious: handlePrevious,
|
onPrevious: pagination.handlePrevious,
|
||||||
onNext: handleNext,
|
onNext: () => pagination.handleNext(operations.data?.nextCursor),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,262 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useBrokerPositions } from '@/entities/broker-position';
|
||||||
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 { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
||||||
import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters';
|
import { BrokerPositionTable } from '@/widgets/broker-positions-table';
|
||||||
|
import { useCursorPagination } from '@/shared/lib/useCursorPagination';
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type BrokerPositionsPageProps = {
|
type BrokerPositionsPageProps = {
|
||||||
type: 'share' | 'bond';
|
type: 'share' | 'bond';
|
||||||
@ -265,22 +10,8 @@ type BrokerPositionsPageProps = {
|
|||||||
|
|
||||||
export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
|
export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
|
||||||
const { accountId } = useBrokerAccountContext();
|
const { accountId } = useBrokerAccountContext();
|
||||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
const pagination = useCursorPagination();
|
||||||
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
|
const positions = useBrokerPositions(accountId, { type, limit: 10, cursor: pagination.cursor });
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (positions.error) {
|
if (positions.error) {
|
||||||
return (
|
return (
|
||||||
@ -302,9 +33,9 @@ export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
|
|||||||
isLoading={positions.isLoading}
|
isLoading={positions.isLoading}
|
||||||
isFetching={positions.isFetching}
|
isFetching={positions.isFetching}
|
||||||
emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'}
|
emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'}
|
||||||
pageNumber={cursorStack.length + 1}
|
pageNumber={pagination.pageNumber}
|
||||||
onNext={handleNext}
|
onNext={() => pagination.handleNext(positions.data?.nextCursor)}
|
||||||
onPrevious={handlePrevious}
|
onPrevious={pagination.handlePrevious}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { PortfolioSummary } from '@/widgets/portfolio-summary';
|
|||||||
import { AnalyticsSummary } from '@/widgets/portfolio-analytics';
|
import { AnalyticsSummary } from '@/widgets/portfolio-analytics';
|
||||||
import { SharePositionTable } from '@/widgets/share-positions-table';
|
import { SharePositionTable } from '@/widgets/share-positions-table';
|
||||||
import { BondPositionTable } from '@/widgets/bond-positions-table';
|
import { BondPositionTable } from '@/widgets/bond-positions-table';
|
||||||
|
import { AddPositionForm } from '@/features/add-position';
|
||||||
|
|
||||||
export function PortfolioDetailPage() {
|
export function PortfolioDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@ -13,18 +14,10 @@ export function PortfolioDetailPage() {
|
|||||||
|
|
||||||
const { data: portfolio, isLoading, error } = usePortfolio(portfolioId);
|
const { data: portfolio, isLoading, error } = usePortfolio(portfolioId);
|
||||||
const { update, remove } = usePortfolioMutations();
|
const { update, remove } = usePortfolioMutations();
|
||||||
const {
|
const { update: updatePosition, remove: removePosition } = usePositionMutations(portfolioId);
|
||||||
update: updatePosition,
|
|
||||||
remove: removePosition,
|
|
||||||
add: addPosition,
|
|
||||||
} = usePositionMutations(portfolioId);
|
|
||||||
|
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [showAddForm, setShowAddForm] = 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) {
|
if (isLoading) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
@ -167,108 +140,7 @@ export function PortfolioDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAddForm && (
|
{showAddForm && <AddPositionForm portfolioId={portfolioId} />}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SharePositionTable
|
<SharePositionTable
|
||||||
positions={portfolio.positions.filter((p) => p.type === 'share')}
|
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 { render, type RenderOptions } from '@testing-library/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { SessionProvider } from '@/app/providers';
|
import { TestSessionProvider } from './TestSessionProvider';
|
||||||
|
|
||||||
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||||
queryClient?: QueryClient;
|
queryClient?: QueryClient;
|
||||||
@ -33,7 +33,7 @@ export function renderWithProviders(
|
|||||||
initialEntries={[route]}
|
initialEntries={[route]}
|
||||||
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
|
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
|
||||||
>
|
>
|
||||||
<SessionProvider>{children}</SessionProvider>
|
<TestSessionProvider>{children}</TestSessionProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</QueryClientProvider>
|
</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"
|
"react-router-dom": "^6.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
@ -3416,6 +3417,36 @@
|
|||||||
"node": ">=0.1.90"
|
"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": {
|
"node_modules/@csstools/cascade-layer-name-parser": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.5.tgz",
|
"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