feat(broker): per-type positions pagination with independent tables
All checks were successful
All checks were successful
- Add type query param to GET /accounts/:accountId/positions endpoint - Backend filters T-Bank portfolio positions by instrument type before pagination - Each instrument type (share, bond, etf, fund) has its own frontend table with independent cursor-based pagination and skeleton loading - Groups with no positions are automatically hidden - Cache key includes type for correct per-type caching - Remove centralized positions pagination state from BrokerAccountDetailPage - 94 backend tests / 112 frontend tests pass
This commit is contained in:
parent
5ccd421259
commit
49ee364856
@ -9,6 +9,7 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
|
||||
- **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria.
|
||||
- **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans/executing-plans для крупных многошаговых работ, frontend-design для UI, requesting-code-review перед завершением крупных изменений.
|
||||
- **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
|
||||
- **Visual Companion**: при обсуждении дизайна UI (mockups, макеты, варианты внешнего вида) использовать visual companion в браузере.
|
||||
|
||||
## Git workflow
|
||||
|
||||
|
||||
@ -15,4 +15,9 @@ export class BrokerPositionQueryDto {
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 10;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by instrument type (share, bond, etf, etc.)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
type?: string;
|
||||
}
|
||||
|
||||
@ -70,14 +70,7 @@ describe('BrokerPortfolioService', () => {
|
||||
});
|
||||
|
||||
describe('getPositions', () => {
|
||||
it('throws 404 for missing account', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue(null);
|
||||
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||||
|
||||
await expect(service.getPositions('missing')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('returns first page of positions', async () => {
|
||||
function mockAccount() {
|
||||
vi.mocked(accounts.findById).mockResolvedValue({
|
||||
id: 'acc-1',
|
||||
type: 'brokerage',
|
||||
@ -86,6 +79,9 @@ describe('BrokerPortfolioService', () => {
|
||||
openedAt: null,
|
||||
accessLevel: null,
|
||||
});
|
||||
}
|
||||
|
||||
function mockCache() {
|
||||
vi.mocked(cache.getOrFetch).mockImplementation(
|
||||
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
||||
data: await fetchFn(),
|
||||
@ -93,6 +89,18 @@ describe('BrokerPortfolioService', () => {
|
||||
cachedAt: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it('throws 404 for missing account', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue(null);
|
||||
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||||
|
||||
await expect(service.getPositions('missing')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('returns first page of positions', async () => {
|
||||
mockAccount();
|
||||
mockCache();
|
||||
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||
getPortfolio: vi.fn(),
|
||||
} as any);
|
||||
@ -126,21 +134,8 @@ describe('BrokerPortfolioService', () => {
|
||||
});
|
||||
|
||||
it('paginates using cursor', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue({
|
||||
id: 'acc-1',
|
||||
type: 'brokerage',
|
||||
name: 'Broker',
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
openedAt: null,
|
||||
accessLevel: null,
|
||||
});
|
||||
vi.mocked(cache.getOrFetch).mockImplementation(
|
||||
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}),
|
||||
);
|
||||
mockAccount();
|
||||
mockCache();
|
||||
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||
getPortfolio: vi.fn(),
|
||||
} as any);
|
||||
@ -179,21 +174,8 @@ describe('BrokerPortfolioService', () => {
|
||||
});
|
||||
|
||||
it('returns last page with hasNext=false', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue({
|
||||
id: 'acc-1',
|
||||
type: 'brokerage',
|
||||
name: 'Broker',
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
openedAt: null,
|
||||
accessLevel: null,
|
||||
});
|
||||
vi.mocked(cache.getOrFetch).mockImplementation(
|
||||
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}),
|
||||
);
|
||||
mockAccount();
|
||||
mockCache();
|
||||
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||
getPortfolio: vi.fn(),
|
||||
} as any);
|
||||
@ -218,22 +200,9 @@ describe('BrokerPortfolioService', () => {
|
||||
expect(result.data.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it('caches positions with cursor/limit in key and tbankPositionsTtl', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue({
|
||||
id: 'acc-1',
|
||||
type: 'brokerage',
|
||||
name: 'Broker',
|
||||
status: 'ACCOUNT_STATUS_OPEN',
|
||||
openedAt: null,
|
||||
accessLevel: null,
|
||||
});
|
||||
vi.mocked(cache.getOrFetch).mockImplementation(
|
||||
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}),
|
||||
);
|
||||
it('caches positions with cursor/limit/type in key and tbankPositionsTtl', async () => {
|
||||
mockAccount();
|
||||
mockCache();
|
||||
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||
getPortfolio: vi.fn(),
|
||||
} as any);
|
||||
@ -248,10 +217,89 @@ describe('BrokerPortfolioService', () => {
|
||||
|
||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||
'tbank:positions',
|
||||
['acc-1', 'some-cursor', '5'],
|
||||
['acc-1', 'some-cursor', '5', ''],
|
||||
expect.any(Function),
|
||||
'tbankPositionsTtl',
|
||||
);
|
||||
});
|
||||
|
||||
it('filters by instrument type and caches with type in key', async () => {
|
||||
mockAccount();
|
||||
mockCache();
|
||||
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||
getPortfolio: vi.fn(),
|
||||
} as any);
|
||||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||
accountId: 'acc-1',
|
||||
totalAmountPortfolio: { currency: 'rub', units: '5000', nano: 0 },
|
||||
positions: [
|
||||
{
|
||||
figi: 'f1',
|
||||
instrumentUid: 'u1',
|
||||
positionUid: 'p1',
|
||||
instrumentType: 'share',
|
||||
ticker: 'SBER',
|
||||
quantity: { units: '10', nano: 0 },
|
||||
},
|
||||
{
|
||||
figi: 'f2',
|
||||
instrumentUid: 'u2',
|
||||
positionUid: 'p2',
|
||||
instrumentType: 'bond',
|
||||
ticker: 'SU26238RMFS5',
|
||||
quantity: { units: '5', nano: 0 },
|
||||
},
|
||||
{
|
||||
figi: 'f3',
|
||||
instrumentUid: 'u3',
|
||||
positionUid: 'p3',
|
||||
instrumentType: 'share',
|
||||
ticker: 'GAZP',
|
||||
quantity: { units: '3', nano: 0 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||||
const result = await service.getPositions('acc-1', undefined, 10, 'share');
|
||||
|
||||
expect(result.data.items).toHaveLength(2);
|
||||
expect(result.data.items.map((i) => i.ticker)).toEqual(['SBER', 'GAZP']);
|
||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||
'tbank:positions',
|
||||
['acc-1', '', '10', 'share'],
|
||||
expect.any(Function),
|
||||
'tbankPositionsTtl',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty items when type filter matches nothing', async () => {
|
||||
mockAccount();
|
||||
mockCache();
|
||||
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||
getPortfolio: vi.fn(),
|
||||
} as any);
|
||||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||
accountId: 'acc-1',
|
||||
totalAmountPortfolio: { currency: 'rub', units: '5000', nano: 0 },
|
||||
positions: [
|
||||
{
|
||||
figi: 'f1',
|
||||
instrumentUid: 'u1',
|
||||
positionUid: 'p1',
|
||||
instrumentType: 'share',
|
||||
ticker: 'SBER',
|
||||
quantity: { units: '10', nano: 0 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||||
const result = await service.getPositions('acc-1', undefined, 10, 'etf');
|
||||
|
||||
expect(result.data.items).toHaveLength(0);
|
||||
expect(result.data.hasNext).toBe(false);
|
||||
expect(result.data.nextCursor).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -66,6 +66,7 @@ export class BrokerPortfolioService {
|
||||
accountId: string,
|
||||
cursor?: string,
|
||||
limit = 10,
|
||||
type?: string,
|
||||
): Promise<{
|
||||
data: BrokerPositionsPage;
|
||||
meta: { fromCache: boolean; cachedAt: string | null };
|
||||
@ -75,7 +76,7 @@ export class BrokerPortfolioService {
|
||||
|
||||
const result = await this.cacheService.getOrFetch(
|
||||
TBANK_CACHE_KEYS.positions,
|
||||
[accountId, cursor ?? '', String(limit)],
|
||||
[accountId, cursor ?? '', String(limit), type ?? ''],
|
||||
async () => {
|
||||
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
|
||||
const portfolio = await this.tbankClient.callUnary<
|
||||
@ -86,11 +87,18 @@ export class BrokerPortfolioService {
|
||||
currency: 'RUB',
|
||||
});
|
||||
|
||||
const instrumentMap = await this.buildInstrumentMap(portfolio);
|
||||
const filteredPositions = type
|
||||
? (portfolio.positions ?? []).filter(
|
||||
(p) => p.instrumentType?.toLowerCase() === type.toLowerCase(),
|
||||
)
|
||||
: portfolio.positions;
|
||||
|
||||
const filteredPortfolio = { ...portfolio, positions: filteredPositions };
|
||||
const instrumentMap = await this.buildInstrumentMap(filteredPortfolio);
|
||||
|
||||
return mapBrokerPositionsPage({
|
||||
accountId,
|
||||
portfolio,
|
||||
portfolio: filteredPortfolio,
|
||||
instruments: instrumentMap,
|
||||
cursor,
|
||||
limit,
|
||||
|
||||
@ -56,6 +56,7 @@ export class TBankController {
|
||||
accountId,
|
||||
query.cursor,
|
||||
query.limit,
|
||||
query.type,
|
||||
);
|
||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||
}
|
||||
|
||||
@ -21,18 +21,18 @@
|
||||
"react-router-dom": "^6.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"eslint": "^8.0.0",
|
||||
"eslint-plugin-react": "^7.34.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"eslint": "^8.0.0",
|
||||
"eslint-plugin-react": "^7.34.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"msw": "^2.14.6",
|
||||
"openapi-typescript": "^7.0.0",
|
||||
|
||||
@ -53,13 +53,14 @@ export function getBrokerOperations(
|
||||
|
||||
export function getBrokerPositions(
|
||||
accountId: string,
|
||||
query: { cursor?: string; limit?: number } = {},
|
||||
query: { cursor?: string; limit?: number; type?: string } = {},
|
||||
): Promise<{ data: BrokerPositionsPage; meta: ApiResponseMeta }> {
|
||||
return request<BrokerPositionsPage>(
|
||||
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/positions`,
|
||||
{
|
||||
cursor: query.cursor,
|
||||
limit: query.limit ? String(query.limit) : undefined,
|
||||
type: query.type,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import type { BrokerPositionsPage } from '../api/responses';
|
||||
|
||||
export function useBrokerPositions(
|
||||
accountId: string | undefined,
|
||||
query: { cursor?: string; limit?: number } = {},
|
||||
query: { cursor?: string; limit?: number; type?: string } = {},
|
||||
) {
|
||||
return useQuery<BrokerPositionsPage>({
|
||||
queryKey: ['broker', 'positions', accountId, query],
|
||||
|
||||
@ -3,7 +3,6 @@ import { useParams } from 'react-router-dom';
|
||||
import type { BrokerMoney } from '../../api/responses';
|
||||
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
|
||||
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
|
||||
import { useBrokerPositions } from '../../hooks/useBrokerPositions';
|
||||
import { BrokerOperationsTable } from './BrokerOperationsTable';
|
||||
import { BrokerPositionsSection } from './BrokerPositionsSection';
|
||||
import { SkeletonBlock } from '../../components/SkeletonBlock';
|
||||
@ -21,11 +20,8 @@ export function BrokerAccountDetailPage() {
|
||||
const { accountId } = useParams();
|
||||
const [operationCursor, setOperationCursor] = useState<string | undefined>(undefined);
|
||||
const [operationCursorStack, setOperationCursorStack] = useState<Array<string | undefined>>([]);
|
||||
const [positionCursor, setPositionCursor] = useState<string | undefined>(undefined);
|
||||
const [positionCursorStack, setPositionCursorStack] = useState<Array<string | undefined>>([]);
|
||||
const portfolio = useBrokerPortfolio(accountId);
|
||||
const operations = useBrokerOperations(accountId, { limit: 10, cursor: operationCursor });
|
||||
const positions = useBrokerPositions(accountId, { limit: 10, cursor: positionCursor });
|
||||
|
||||
if (portfolio.isLoading) {
|
||||
return (
|
||||
@ -57,87 +53,6 @@ export function BrokerAccountDetailPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
align="left"
|
||||
style={{
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontWeight: 600,
|
||||
padding: '10px 8px',
|
||||
}}
|
||||
>
|
||||
Тикер
|
||||
</th>
|
||||
<th
|
||||
align="left"
|
||||
style={{
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontWeight: 600,
|
||||
padding: '10px 8px',
|
||||
}}
|
||||
>
|
||||
Название
|
||||
</th>
|
||||
<th
|
||||
align="right"
|
||||
style={{
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontWeight: 600,
|
||||
padding: '10px 8px',
|
||||
}}
|
||||
>
|
||||
Количество
|
||||
</th>
|
||||
<th
|
||||
align="right"
|
||||
style={{
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontWeight: 600,
|
||||
padding: '10px 8px',
|
||||
}}
|
||||
>
|
||||
Цена
|
||||
</th>
|
||||
<th
|
||||
align="right"
|
||||
style={{
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontWeight: 600,
|
||||
padding: '10px 8px',
|
||||
}}
|
||||
>
|
||||
Стоимость
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: 5 }).map((_, j) => (
|
||||
<td
|
||||
key={j}
|
||||
style={{
|
||||
borderBottom: '1px solid #eeeeee',
|
||||
padding: '10px 8px',
|
||||
verticalAlign: 'top',
|
||||
}}
|
||||
>
|
||||
<SkeletonBlock height={12} width={`${30 + j * 10}%`} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -161,21 +76,6 @@ export function BrokerAccountDetailPage() {
|
||||
setOperationCursor(previousCursor);
|
||||
}
|
||||
|
||||
function handleNextPositionsPage() {
|
||||
const nextCursor = positions.data?.nextCursor;
|
||||
if (!nextCursor || !positions.data?.hasNext) return;
|
||||
setPositionCursorStack((previous) => [...previous, positionCursor]);
|
||||
setPositionCursor(nextCursor);
|
||||
}
|
||||
|
||||
function handlePreviousPositionsPage() {
|
||||
if (positionCursorStack.length === 0) return;
|
||||
const nextStack = positionCursorStack.slice(0, -1);
|
||||
const previousCursor = positionCursorStack[positionCursorStack.length - 1];
|
||||
setPositionCursorStack(nextStack);
|
||||
setPositionCursor(previousCursor);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 24 }}>
|
||||
<header>
|
||||
@ -218,15 +118,7 @@ export function BrokerAccountDetailPage() {
|
||||
))}
|
||||
</section>
|
||||
|
||||
<BrokerPositionsSection
|
||||
page={positions.data}
|
||||
isLoading={positions.isLoading}
|
||||
pageNumber={positionCursorStack.length + 1}
|
||||
canGoBack={positionCursorStack.length > 0}
|
||||
canGoForward={Boolean(positions.data?.hasNext && positions.data.nextCursor)}
|
||||
onPrevious={handlePreviousPositionsPage}
|
||||
onNext={handleNextPositionsPage}
|
||||
/>
|
||||
<BrokerPositionsSection accountId={accountId!} />
|
||||
|
||||
<BrokerOperationsTable
|
||||
isLoading={operations.isLoading}
|
||||
|
||||
@ -8,6 +8,7 @@ import * as accountHook from '../../hooks/useBrokerAccounts';
|
||||
import * as operationsHook from '../../hooks/useBrokerOperations';
|
||||
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
|
||||
import * as positionsHook from '../../hooks/useBrokerPositions';
|
||||
import type { BrokerPosition } from '../../api/responses';
|
||||
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
|
||||
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
||||
|
||||
@ -21,6 +22,45 @@ function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) {
|
||||
);
|
||||
}
|
||||
|
||||
function createPosition(input: Partial<BrokerPosition>): BrokerPosition {
|
||||
return {
|
||||
figi: null,
|
||||
instrumentUid: null,
|
||||
positionUid: null,
|
||||
ticker: null,
|
||||
classCode: null,
|
||||
instrumentType: null,
|
||||
name: null,
|
||||
quantity: null,
|
||||
blockedLots: null,
|
||||
currentPrice: null,
|
||||
currentValue: null,
|
||||
averagePositionPrice: null,
|
||||
expectedYieldPercent: null,
|
||||
dailyYield: null,
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
/** Spy on useBrokerPositions and return only positions matching query.type . */
|
||||
function mockUseBrokerPositions(...positions: BrokerPosition[]) {
|
||||
return vi.spyOn(positionsHook, 'useBrokerPositions').mockImplementation((_accountId, query) => {
|
||||
const type = query.type?.toLowerCase();
|
||||
const filtered = type ? positions.filter((p) => p.instrumentType?.toLowerCase() === type) : [];
|
||||
return {
|
||||
data: {
|
||||
accountId: 'acc-1',
|
||||
items: filtered,
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
asOf: '2026-06-17T00:00:00.000Z',
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any;
|
||||
});
|
||||
}
|
||||
|
||||
describe('Broker pages', () => {
|
||||
it('renders broker and IIS accounts', () => {
|
||||
vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({
|
||||
@ -107,34 +147,17 @@ describe('Broker pages', () => {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({
|
||||
data: {
|
||||
accountId: 'acc-1',
|
||||
items: [
|
||||
{
|
||||
figi: null,
|
||||
mockUseBrokerPositions(
|
||||
createPosition({
|
||||
instrumentUid: 'uid-1',
|
||||
positionUid: null,
|
||||
ticker: 'SBER',
|
||||
classCode: 'TQBR',
|
||||
instrumentType: 'share',
|
||||
name: 'Sberbank',
|
||||
quantity: 10,
|
||||
blockedLots: null,
|
||||
currentPrice: null,
|
||||
currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
|
||||
averagePositionPrice: null,
|
||||
expectedYieldPercent: null,
|
||||
dailyYield: null,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
asOf: '2026-06-16T00:00:00.000Z',
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithClient(
|
||||
<Routes>
|
||||
@ -178,50 +201,30 @@ describe('Broker pages', () => {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({
|
||||
data: {
|
||||
accountId: 'acc-1',
|
||||
items: [
|
||||
{
|
||||
figi: null,
|
||||
mockUseBrokerPositions(
|
||||
createPosition({
|
||||
instrumentUid: 'share-uid',
|
||||
positionUid: null,
|
||||
ticker: 'SBER',
|
||||
classCode: 'TQBR',
|
||||
instrumentType: 'share',
|
||||
name: 'Sberbank',
|
||||
quantity: 10,
|
||||
blockedLots: null,
|
||||
currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 },
|
||||
currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 },
|
||||
averagePositionPrice: null,
|
||||
expectedYieldPercent: 20,
|
||||
dailyYield: null,
|
||||
},
|
||||
{
|
||||
figi: null,
|
||||
}),
|
||||
createPosition({
|
||||
instrumentUid: 'bond-uid',
|
||||
positionUid: null,
|
||||
ticker: 'SU26238RMFS5',
|
||||
classCode: 'TQOB',
|
||||
instrumentType: 'bond',
|
||||
name: 'ОФЗ 26238',
|
||||
quantity: 2,
|
||||
blockedLots: null,
|
||||
currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 },
|
||||
currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 },
|
||||
averagePositionPrice: null,
|
||||
expectedYieldPercent: 10,
|
||||
dailyYield: null,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
asOf: '2026-06-17T00:00:00.000Z',
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithClient(
|
||||
<Routes>
|
||||
@ -321,17 +324,7 @@ describe('Broker pages', () => {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({
|
||||
data: {
|
||||
accountId: 'acc-1',
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
asOf: '2026-06-17T00:00:00.000Z',
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
mockUseBrokerPositions();
|
||||
|
||||
renderWithClient(
|
||||
<Routes>
|
||||
@ -441,17 +434,7 @@ describe('Broker pages', () => {
|
||||
error: null,
|
||||
}) as any,
|
||||
);
|
||||
vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({
|
||||
data: {
|
||||
accountId: 'acc-1',
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
asOf: '2026-06-17T00:00:00.000Z',
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
mockUseBrokerPositions();
|
||||
|
||||
renderWithClient(
|
||||
<Routes>
|
||||
@ -462,7 +445,6 @@ describe('Broker pages', () => {
|
||||
|
||||
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
|
||||
|
||||
// Scope pagination queries to the operations section (positions section also has pagination now)
|
||||
const operationsSection = screen.getByRole('heading', { name: 'Операции' }).closest('section')!;
|
||||
const withinOperations = within(operationsSection);
|
||||
const nextButton = withinOperations.getByRole('button', { name: '→' });
|
||||
@ -477,7 +459,6 @@ describe('Broker pages', () => {
|
||||
cursor: 'cursor-page-2',
|
||||
});
|
||||
|
||||
// Page number is shown as just a number (without "Страница" label)
|
||||
expect(withinOperations.getByText('2')).toBeInTheDocument();
|
||||
|
||||
await user.click(prevButton);
|
||||
|
||||
@ -1,19 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { BrokerMoney, BrokerPosition } from '../../api/responses';
|
||||
import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay';
|
||||
import { getBrokerInstrumentPath } from './brokerDisplay';
|
||||
import { TableSkeleton } from '../../components/TableSkeleton';
|
||||
import { useBrokerPositions } from '../../hooks/useBrokerPositions';
|
||||
|
||||
type BrokerPositionGroupConfig = {
|
||||
key: 'shares' | 'bonds' | 'other';
|
||||
key: string;
|
||||
type?: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const GROUPS: BrokerPositionGroupConfig[] = [
|
||||
{ key: 'shares', title: 'Акции' },
|
||||
{ key: 'bonds', title: 'Облигации' },
|
||||
{ key: 'other', title: 'Другие инструменты' },
|
||||
{ key: 'shares', type: 'share', title: 'Акции' },
|
||||
{ key: 'bonds', type: 'bond', title: 'Облигации' },
|
||||
{ key: 'etf', type: 'etf', title: 'ETF' },
|
||||
{ key: 'fund', type: 'fund', title: 'Фонды' },
|
||||
];
|
||||
|
||||
const KNOWN_TYPES = new Set(GROUPS.map((g) => g.type).filter(Boolean));
|
||||
|
||||
const tableStyle = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
@ -83,89 +89,47 @@ function PositionTicker({ position }: { position: BrokerPosition }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PositionTable({ title, positions }: { title: string; positions: BrokerPosition[] }) {
|
||||
return (
|
||||
<section>
|
||||
<h3 style={{ fontSize: 18, marginBottom: 10 }}>{title}</h3>
|
||||
<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>
|
||||
</section>
|
||||
function PositionGroupTable({
|
||||
accountId,
|
||||
group,
|
||||
}: {
|
||||
accountId: string;
|
||||
group: BrokerPositionGroupConfig;
|
||||
}) {
|
||||
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
|
||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||
|
||||
const query = group.type ? { type: group.type, limit: 10, cursor } : { limit: 100, cursor };
|
||||
const { data: page, isLoading } = useBrokerPositions(accountId, query);
|
||||
|
||||
const rawPositions = page?.items ?? [];
|
||||
const positions = group.type
|
||||
? rawPositions
|
||||
: rawPositions.filter(
|
||||
(p) => p.instrumentType && !KNOWN_TYPES.has(p.instrumentType.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
type BrokerPositionsSectionProps = {
|
||||
page: { items: BrokerPosition[] } | undefined;
|
||||
isLoading: boolean;
|
||||
pageNumber: number;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
};
|
||||
const pageNumber = cursorStack.length + 1;
|
||||
const canGoBack = cursorStack.length > 0;
|
||||
const canGoForward = Boolean(page?.hasNext && page.nextCursor && !!group.type);
|
||||
|
||||
export function BrokerPositionsSection({
|
||||
page,
|
||||
isLoading,
|
||||
pageNumber,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
onPrevious,
|
||||
onNext,
|
||||
}: BrokerPositionsSectionProps) {
|
||||
const positions = page?.items ?? [];
|
||||
function handleNext() {
|
||||
const nextCursor = page?.nextCursor;
|
||||
if (!nextCursor || !page?.hasNext || !group.type) return;
|
||||
setCursorStack((prev) => [...prev, cursor]);
|
||||
setCursor(nextCursor);
|
||||
}
|
||||
|
||||
const grouped = GROUPS.map((group) => ({
|
||||
...group,
|
||||
positions: positions.filter((position) => getBrokerPositionGroup(position) === group.key),
|
||||
})).filter((group) => group.positions.length > 0);
|
||||
function handlePrevious() {
|
||||
if (cursorStack.length === 0) return;
|
||||
const prev = cursorStack[cursorStack.length - 1];
|
||||
setCursorStack((prevStack) => prevStack.slice(0, -1));
|
||||
setCursor(prev);
|
||||
}
|
||||
|
||||
if (!isLoading && positions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
@ -175,14 +139,15 @@ export function BrokerPositionsSection({
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 20, margin: 0 }}>Позиции</h2>
|
||||
<h3 style={{ fontSize: 18, margin: 0 }}>{group.title}</h3>
|
||||
{group.type && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrevious}
|
||||
onClick={handlePrevious}
|
||||
disabled={!canGoBack}
|
||||
style={canGoBack ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
@ -201,16 +166,17 @@ export function BrokerPositionsSection({
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
onClick={handleNext}
|
||||
disabled={!canGoForward}
|
||||
style={canGoForward ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && grouped.length === 0 ? (
|
||||
{isLoading && (
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
<table style={tableStyle}>
|
||||
<thead>
|
||||
@ -244,16 +210,11 @@ export function BrokerPositionsSection({
|
||||
/>
|
||||
</table>
|
||||
</div>
|
||||
) : grouped.length === 0 ? (
|
||||
<p style={{ color: 'var(--color-text-secondary)' }}>В портфеле нет позиций</p>
|
||||
) : isLoading ? (
|
||||
<div>
|
||||
<div style={{ display: 'grid', gap: 20 }}>
|
||||
{grouped.map((group) => (
|
||||
<div key={group.key}>
|
||||
<h3 style={{ fontSize: 18, marginBottom: 10 }}>{group.title}</h3>
|
||||
)}
|
||||
|
||||
{!isLoading && positions.length > 0 && (
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
<table style={tableStyle}>
|
||||
<table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" style={thStyle}>
|
||||
@ -273,29 +234,54 @@ export function BrokerPositionsSection({
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<TableSkeleton
|
||||
rows={2}
|
||||
columns={[
|
||||
{ width: '30%' },
|
||||
{ width: '50%' },
|
||||
{ width: '20%' },
|
||||
{ width: '25%' },
|
||||
{ width: '25%' },
|
||||
]}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 20 }}>
|
||||
{grouped.map((group) => (
|
||||
<PositionTable key={group.key} title={group.title} positions={group.positions} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type BrokerPositionsSectionProps = {
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
export function BrokerPositionsSection({ accountId }: BrokerPositionsSectionProps) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 20 }}>
|
||||
<h2 style={{ fontSize: 20, margin: 0 }}>Позиции</h2>
|
||||
{GROUPS.map((group) => (
|
||||
<PositionGroupTable key={group.key} accountId={accountId} group={group} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,269 @@
|
||||
# Пагинация позиций, скелетоны, название инструмента в операциях
|
||||
|
||||
Дата: 2026-06-17
|
||||
Статус: черновик
|
||||
|
||||
## Контекст
|
||||
|
||||
Страница брокерского счёта показывает таблицы позиций (Акции, Облигации, Другие инструменты) и
|
||||
операций. Сейчас позиции приходят единым списком внутри `GET /portfolio`, что неэффективно при
|
||||
большом количестве позиций. Также отсутствуют loading-индикаторы (просто текст "Загрузка...").
|
||||
|
||||
## Цель
|
||||
|
||||
1. Выделить позиции в отдельный paginated endpoint (10 на страницу)
|
||||
2. Заменить текстовые loading-индикаторы на shimmer-скелетоны
|
||||
3. Добавить название инструмента в колонку "Инструмент" таблицы операций
|
||||
4. Добавить визуальный loading-индикатор при переключении страниц таблиц
|
||||
|
||||
## Изменения
|
||||
|
||||
### 1. Backend: отдельный endpoint для позиций
|
||||
|
||||
**Новый endpoint:** `GET /api/v1/broker/accounts/:accountId/positions`
|
||||
|
||||
Query params:
|
||||
- `cursor` — positionUid последней позиции на тек. странице (string, опционально)
|
||||
- `limit` — размер страницы (number, default 10)
|
||||
|
||||
Response:
|
||||
```ts
|
||||
interface BrokerPositionsPage {
|
||||
accountId: string;
|
||||
items: BrokerPosition[];
|
||||
nextCursor: string | null;
|
||||
hasNext: boolean;
|
||||
asOf: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Логика:**
|
||||
- `broker-portfolio.service.ts` уже делает gRPC вызов `GetPortfolio`, который возвращает все позиции
|
||||
- Новый метод `getPositions(accountId, cursor?, limit?)` делает тот же gRPC вызов, кэширует полный список,
|
||||
затем возвращает paginated slice
|
||||
- Cursor: позиция с `positionUid === cursor` — начало следующей страницы
|
||||
- Кэширование: `CACHE_POSITIONS_TTL` (60s) — отдельно от портфеля, т.к. цены меняются быстро
|
||||
- Если `cursor` не указан — возвращается первая страница
|
||||
|
||||
**Изменение `BrokerPortfolio`:** убрать `positions` из типа/DTO портфеля.
|
||||
Фронтенд теперь грузит позиции отдельным запросом.
|
||||
|
||||
**Новый файл:** `dto/broker-positions-page-response.dto.ts`
|
||||
|
||||
**Изменяемые backend-файлы:**
|
||||
| Файл | Изменение |
|
||||
|---|---|
|
||||
| `types/broker.types.ts` | Добавить `BrokerPositionsPage` тип. Убрать `positions` из `BrokerPortfolio` |
|
||||
| `dto/broker-portfolio-response.dto.ts` | Убрать `positions` из `BrokerPortfolioResponseDto` |
|
||||
| `dto/broker-position-response.dto.ts` | Создать (перенести `BrokerPositionResponseDto` сюда из portfolio) |
|
||||
| `dto/broker-positions-page-response.dto.ts` | Создать |
|
||||
| `services/broker-portfolio.service.ts` | Добавить `getPositions()`, убрать positions из `getPortfolio()` |
|
||||
| `mappers/portfolio.mapper.ts` | Разделить маппинг: `mapBrokerPortfolio()` без positions, `mapBrokerPosition()` отдельно |
|
||||
| `tbank.controller.ts` | Добавить `GET /accounts/:accountId/positions` |
|
||||
| `tbank.config.ts` | Добавить `CACHE_POSITIONS_TTL` (60s) |
|
||||
| `operation.mapper.ts` | Добавить `name: item.name ?? null` в `mapOperation()` |
|
||||
| `types/broker.types.ts` | Добавить `name` в `BrokerOperation` |
|
||||
| `dto/broker-operation-response.dto.ts` | Добавить `name` |
|
||||
|
||||
### 2. Frontend: новый хук и типы для позиций
|
||||
|
||||
**Новый хук:** `apps/frontend/src/hooks/useBrokerPositions.ts`
|
||||
```ts
|
||||
export function useBrokerPositions(accountId, query = {}) {
|
||||
return useQuery<BrokerPositionsPage>({
|
||||
queryKey: ['broker', 'positions', accountId, query],
|
||||
enabled: Boolean(accountId),
|
||||
queryFn: () => getBrokerPositions(accountId!, query),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 60_000,
|
||||
retry: 2,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Новый API-вызов:** `apps/frontend/src/api/broker.ts`
|
||||
```ts
|
||||
export function getBrokerPositions(accountId, query) { ... }
|
||||
```
|
||||
|
||||
**Новые типы в `responses.ts`:**
|
||||
- `BrokerPositionsPage` — интерфейс с items, nextCursor, hasNext
|
||||
- `name: string | null` в `BrokerOperation`
|
||||
- Убрать `positions` из `BrokerPortfolio`
|
||||
|
||||
### 3. BrokerPositionsSection с пагинацией
|
||||
|
||||
Компонент теперь принимает пропсы для пагинации (как BrokerOperationsTable):
|
||||
|
||||
```tsx
|
||||
interface Props {
|
||||
page: BrokerPositionsPage | undefined;
|
||||
isLoading: boolean;
|
||||
pageNumber: number;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Логика:**
|
||||
- `BrokerPositionsSection` рендерит те же группы (Акции / Облигации / Другие инструменты),
|
||||
но только для позиций с текущей страницы
|
||||
- Снизу — кнопки пагинации ← N →
|
||||
- При `isLoading=true` — показывать 5 shimmer-строк (вместо реальных данных)
|
||||
- При `isLoading=true` и отсутствии данных (первая загрузка) — показывать
|
||||
PositionTable skeleton (shimmer-строки для заглушки)
|
||||
|
||||
### 4. Shimmer-скелетоны (CSS + компоненты)
|
||||
|
||||
**CSS в `styles.css`:**
|
||||
```css
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#eee 25%,
|
||||
#f5f5f5 50%,
|
||||
#eee 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 4px;
|
||||
}
|
||||
```
|
||||
|
||||
**Компонент `SkeletonBlock`:**
|
||||
```tsx
|
||||
function SkeletonBlock({ width, height, borderRadius = 4 }: {
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
borderRadius?: number;
|
||||
}) {
|
||||
return <div className="skeleton" style={{ width, height, borderRadius }} />;
|
||||
}
|
||||
```
|
||||
|
||||
**BrokerAccountsPage:**
|
||||
- Вместо `<p>Загрузка...</p>` — 3 карточки-скелетона в grid
|
||||
```tsx
|
||||
{isLoading && (
|
||||
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))' }}>
|
||||
{[1,2,3].map(i => (
|
||||
<div key={i} style={{ padding: 20, background: 'var(--color-surface)', borderRadius: 8 }}>
|
||||
<SkeletonBlock height={20} width="60%" />
|
||||
<div style={{ height: 10 }} />
|
||||
<SkeletonBlock height={12} width="40%" />
|
||||
<div style={{ height: 6 }} />
|
||||
<SkeletonBlock height={12} width="30%" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**BrokerAccountDetailPage:**
|
||||
- Вместо `<p>Загрузка портфеля...</p>` — shimmer-блоки под header + cash + positions
|
||||
- Позиции грузятся отдельно через `useBrokerPositions` — свой skeleton
|
||||
|
||||
### 5. Название инструмента в операциях
|
||||
|
||||
**Изменение `OperationInstrument`:**
|
||||
|
||||
```tsx
|
||||
function OperationInstrument({ operation }: { operation: BrokerOperation }) {
|
||||
const ticker = operation.ticker;
|
||||
const path = getBrokerInstrumentPath({ ticker, instrumentType: operation.instrumentType, classCode: operation.classCode });
|
||||
const name = operation.name || operation.description;
|
||||
|
||||
if (!path && !name) return <span>-</span>;
|
||||
if (!path) return <span>{name}</span>;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 2 }}>
|
||||
<Link to={path} style={{ fontWeight: 700 }}>{ticker}</Link>
|
||||
{name && name !== ticker && (
|
||||
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>{name}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Loading-индикатор при переключении страниц (shimmer-строки)
|
||||
|
||||
**BrokerOperationsTable:**
|
||||
- При `isLoading=true` и наличии `page` (уже были данные, но грузится новая страница):
|
||||
показываем 5 shimmer-строк вместо table body
|
||||
- При `isLoading=true` и отсутствии `page` (первая загрузка):
|
||||
показываем header таблицы + 5 shimmer-строк
|
||||
- Используем `keepPreviousData` в TanStack Query, но визуально не показываем старые данные —
|
||||
показываем shimmer-строки
|
||||
|
||||
**BrokerPositionsSection:**
|
||||
- Аналогичное поведение при переключении страниц позиций
|
||||
|
||||
**Компонент `TableSkeleton`:**
|
||||
```tsx
|
||||
function TableSkeleton({ rows = 5 }) {
|
||||
return (
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td style={tdStyle}><SkeletonBlock height={12} width="70%" /></td>
|
||||
<td style={tdStyle}><SkeletonBlock height={12} width="50%" /></td>
|
||||
<td style={tdStyle}><SkeletonBlock height={12} width="30%" /></td>
|
||||
<td style={tdStyle}><SkeletonBlock height={12} width="40%" /></td>
|
||||
<td style={tdStyle}><SkeletonBlock height={12} width="40%" /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Количество колонок и их ширина зависит от таблицы (operations vs positions).
|
||||
|
||||
## Файлы для изменения
|
||||
|
||||
### Backend
|
||||
| Файл | Изменение |
|
||||
|---|---|
|
||||
| `apps/backend/src/modules/tbank/types/broker.types.ts` | Убрать `positions` из `BrokerPortfolio`. Добавить `BrokerPositionsPage`. Добавить `name` в `BrokerOperation` |
|
||||
| `apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts` | Убрать `positions` из `BrokerPortfolioResponseDto`. Вынести `BrokerPositionResponseDto` |
|
||||
| `apps/backend/src/modules/tbank/dto/broker-position-response.dto.ts` | Создать (из `BrokerPositionResponseDto`) |
|
||||
| `apps/backend/src/modules/tbank/dto/broker-positions-page-response.dto.ts` | Создать |
|
||||
| `apps/backend/src/modules/tbank/dto/broker-operation-response.dto.ts` | Добавить `name` |
|
||||
| `apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts` | Разделить маппинг portfolio/positions |
|
||||
| `apps/backend/src/modules/tbank/mappers/operation.mapper.ts` | Добавить `name` в mapOperation |
|
||||
| `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts` | Добавить `getPositions()`, убрать positions из portfolio |
|
||||
| `apps/backend/src/modules/tbank/tbank.controller.ts` | Добавить GET /positions endpoint |
|
||||
| `apps/backend/src/modules/tbank/tbank.config.ts` | Добавить CACHE_POSITIONS_TTL |
|
||||
|
||||
### Frontend
|
||||
| Файл | Изменение |
|
||||
|---|---|
|
||||
| `apps/frontend/src/styles.css` | Добавить `@keyframes shimmer` и `.skeleton` |
|
||||
| `apps/frontend/src/api/responses.ts` | Убрать `positions` из `BrokerPortfolio`. Добавить `BrokerPositionsPage`, `name` в `BrokerOperation` |
|
||||
| `apps/frontend/src/api/broker.ts` | Добавить `getBrokerPositions()` |
|
||||
| `apps/frontend/src/hooks/useBrokerPositions.ts` | Создать |
|
||||
| `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx` | Пагинация + shimmer-строки |
|
||||
| `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx` | Shimmer-строки при loading, обновить OperationInstrument |
|
||||
| `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx` | Скелетоны, хук позиций |
|
||||
| `apps/frontend/src/pages/broker/BrokerAccountsPage.tsx` | Скелетоны |
|
||||
| `apps/frontend/src/pages/broker/BrokerPages.test.tsx` | Обновить тесты |
|
||||
|
||||
## Тестирование
|
||||
|
||||
- Backend: обновить `broker-portfolio.service.spec.ts` — убрать positions из portfolio, покрыть getPositions
|
||||
- Backend: обновить `portfolio.mapper.spec.ts`
|
||||
- Frontend: `npm run test:frontend` — все тесты должны проходить
|
||||
- Проверить, что скелетоны отображаются при загрузке
|
||||
- Проверить, что пагинация позиций работает
|
||||
- Проверить, что shimmer-строки показываются при переключении страниц
|
||||
- Проверить, что название инструмента отображается в операциях
|
||||
Loading…
x
Reference in New Issue
Block a user