Compare commits
No commits in common. "main" and "codex/backend-architecture-improvements" have entirely different histories.
main
...
codex/back
18
AGENTS.md
18
AGENTS.md
@ -507,19 +507,13 @@ roadmap.md и inbox.md никогда не являются основанием
|
|||||||
|
|
||||||
## graphify
|
## graphify
|
||||||
|
|
||||||
This project has a knowledge graph in `graphify-out/` with god nodes, community structure, and cross-file relationships. The graph is a local artifact, not a tracked repo asset.
|
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||||
|
|
||||||
When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else.
|
When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Используй `graphify` в первую очередь, когда задача связана с архитектурой, границами модулей, кросс-файловым влиянием или трассировкой потока данных.
|
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||||
- Для таких вопросов сначала запускай `graphify query "<question>"`, если существует `graphify-out/graph.json`. Для связей используй `graphify path "<A>" "<B>"`, для точечных концептов — `graphify explain "<concept>"`. Обычно это даёт гораздо более узкий подграф, чем `GRAPH_REPORT.md` или raw grep.
|
- Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it.
|
||||||
- Предпочитай `graphify query` перед raw grep, когда нужен кратчайший путь между концептами, мост между комьюнити или трассировка того, как один подсистемный блок достигает другого.
|
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||||
- Для отладки багов начинай с симптома и спрашивай у graphify путь зависимости, bridge nodes или модули, которые могут объяснить неожиданное поведение.
|
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||||
- Если `graphify` возвращает только общую структуру, переходи к `serena` за символ-уровневыми фактами и затем повторяй `graphify` с более узким вопросом, где названы конкретные файлы, модули или сервисы.
|
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||||
- Dirty `graphify-out/` после хуков или инкрементальных обновлений считаются нормой; грязные файлы графа не повод пропускать `graphify`. Пропускать его можно только если задача именно про устаревший или некорректный граф, либо если пользователь прямо попросил не использовать его.
|
|
||||||
- В новом `worktree` сначала заново создай локальный граф командой `graphify extract .`.
|
|
||||||
- После первой сборки в этом `worktree` обновляй граф командой `graphify update .`.
|
|
||||||
- Если существует `graphify-out/wiki/index.md`, используй его для широкого обзора вместо ручного просмотра исходников.
|
|
||||||
- `graphify-out/GRAPH_REPORT.md` читай только для широкого архитектурного обзора или когда `query/path/explain` не дают достаточно контекста.
|
|
||||||
- После изменений в коде запускай `graphify update .`, чтобы держать граф актуальным (только AST, без затрат на LLM).
|
|
||||||
|
|||||||
@ -125,8 +125,6 @@ docs/
|
|||||||
| `npm run format` | Prettier для всех `*.{ts,tsx}` |
|
| `npm run format` | Prettier для всех `*.{ts,tsx}` |
|
||||||
| `npm run codegen -w apps/frontend` | `openapi-typescript` из запущенного локального Swagger → `src/api/types.ts` |
|
| `npm run codegen -w apps/frontend` | `openapi-typescript` из запущенного локального Swagger → `src/api/types.ts` |
|
||||||
|
|
||||||
`graphify-out/` — локальный артефакт знания, он не хранится в git. В новом `worktree` сначала собери его заново: `graphify extract .`; дальше обновляй инкрементально: `graphify update .`. Для вопросов по коду используй `graphify query "..."`.
|
|
||||||
|
|
||||||
Docusaurus (`apps/docs`) — опубликованная документация для пользователей. Storybook (`packages/design-system`) — инженерный workbench для разработки компонентов.
|
Docusaurus (`apps/docs`) — опубликованная документация для пользователей. Storybook (`packages/design-system`) — инженерный workbench для разработки компонентов.
|
||||||
|
|
||||||
Интеграционные тесты с MOEX: `npm run test:integration -w apps/backend`.
|
Интеграционные тесты с MOEX: `npm run test:integration -w apps/backend`.
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
import { ArgumentsHost, BadRequestException, HttpStatus } from '@nestjs/common';
|
|
||||||
import { HttpExceptionFilter } from './http-exception.filter';
|
|
||||||
|
|
||||||
describe('HttpExceptionFilter', () => {
|
|
||||||
const createHost = () => {
|
|
||||||
const json = vi.fn();
|
|
||||||
const status = vi.fn(() => ({ json }));
|
|
||||||
const host = {
|
|
||||||
switchToHttp: () => ({
|
|
||||||
getResponse: () => ({ status }),
|
|
||||||
getRequest: () => ({ url: '/api/v1/test' }),
|
|
||||||
}),
|
|
||||||
} as unknown as ArgumentsHost;
|
|
||||||
|
|
||||||
return { host, status, json };
|
|
||||||
};
|
|
||||||
|
|
||||||
it('does not expose internal Error.message for unhandled exceptions', () => {
|
|
||||||
const filter = new HttpExceptionFilter();
|
|
||||||
const { host, status, json } = createHost();
|
|
||||||
|
|
||||||
filter.catch(new Error('Prisma failed at file:///secret/path'), host);
|
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
|
|
||||||
expect(json).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
message: 'Internal server error',
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
path: '/api/v1/test',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(json.mock.calls[0][0].message).not.toContain('Prisma failed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns safe defaults for non-Error thrown values', () => {
|
|
||||||
const filter = new HttpExceptionFilter();
|
|
||||||
const { host, status, json } = createHost();
|
|
||||||
|
|
||||||
filter.catch('some string error', host);
|
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
|
|
||||||
expect(json).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
message: 'Internal server error',
|
|
||||||
error: 'Internal Server Error',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps HttpException response messages intact', () => {
|
|
||||||
const filter = new HttpExceptionFilter();
|
|
||||||
const { host, status, json } = createHost();
|
|
||||||
|
|
||||||
filter.catch(new BadRequestException('Invalid request'), host);
|
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
|
||||||
expect(json).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
statusCode: HttpStatus.BAD_REQUEST,
|
|
||||||
message: 'Invalid request',
|
|
||||||
error: 'Bad Request',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -26,9 +26,8 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
error = (r.error as string) || exception.name;
|
error = (r.error as string) || exception.name;
|
||||||
}
|
}
|
||||||
} else if (exception instanceof Error) {
|
} else if (exception instanceof Error) {
|
||||||
|
message = exception.message;
|
||||||
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
|
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
|
||||||
} else {
|
|
||||||
this.logger.error(`Unhandled non-error exception: ${String(exception)}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
response.status(status).json({
|
response.status(status).json({
|
||||||
|
|||||||
@ -1,76 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
||||||
|
|
||||||
describe('backend runtime configuration', () => {
|
|
||||||
const OLD_ENV = process.env;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetModules();
|
|
||||||
process.env = { ...OLD_ENV };
|
|
||||||
delete process.env.JWT_SECRET;
|
|
||||||
delete process.env.JWT_REFRESH_SECRET;
|
|
||||||
delete process.env.BACKEND_CORS_ORIGINS;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = OLD_ENV;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps dev auth defaults outside production', async () => {
|
|
||||||
const configuration = (await import('./configuration')).default;
|
|
||||||
|
|
||||||
expect(configuration().auth).toMatchObject({
|
|
||||||
jwtSecret: 'dev-jwt-secret-change-in-production',
|
|
||||||
jwtRefreshSecret: 'dev-refresh-secret-change-in-production',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses backend CORS origins from comma-separated env', async () => {
|
|
||||||
process.env.BACKEND_CORS_ORIGINS = 'https://app.example.com, http://localhost:5173 ';
|
|
||||||
const configuration = (await import('./configuration')).default;
|
|
||||||
|
|
||||||
expect(configuration().cors.origins).toEqual([
|
|
||||||
'https://app.example.com',
|
|
||||||
'http://localhost:5173',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects production defaults for JWT secrets', async () => {
|
|
||||||
const { assertSafeProductionConfig } = await import('../main');
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
assertSafeProductionConfig({
|
|
||||||
nodeEnv: 'production',
|
|
||||||
jwtSecret: 'dev-jwt-secret-change-in-production',
|
|
||||||
jwtRefreshSecret: 'custom-refresh-secret',
|
|
||||||
corsOrigins: ['https://app.example.com'],
|
|
||||||
}),
|
|
||||||
).toThrow('JWT_SECRET must be set to a non-default value in production');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects production credentialed CORS without explicit origins', async () => {
|
|
||||||
const { assertSafeProductionConfig } = await import('../main');
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
assertSafeProductionConfig({
|
|
||||||
nodeEnv: 'production',
|
|
||||||
jwtSecret: 'custom-access-secret',
|
|
||||||
jwtRefreshSecret: 'custom-refresh-secret',
|
|
||||||
corsOrigins: [],
|
|
||||||
}),
|
|
||||||
).toThrow('BACKEND_CORS_ORIGINS must contain at least one origin in production');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows development with reflected CORS', async () => {
|
|
||||||
const { buildCorsOrigin } = await import('../main');
|
|
||||||
|
|
||||||
expect(buildCorsOrigin('development', [])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses explicit production CORS origins', async () => {
|
|
||||||
const { buildCorsOrigin } = await import('../main');
|
|
||||||
|
|
||||||
expect(buildCorsOrigin('production', ['https://app.example.com'])).toEqual([
|
|
||||||
'https://app.example.com',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,14 +1,5 @@
|
|||||||
import { registerAs } from '@nestjs/config';
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
export const DEV_JWT_SECRET = 'dev-jwt-secret-change-in-production';
|
|
||||||
export const DEV_JWT_REFRESH_SECRET = 'dev-refresh-secret-change-in-production';
|
|
||||||
|
|
||||||
const parseCsv = (value: string | undefined): string[] =>
|
|
||||||
(value ?? '')
|
|
||||||
.split(',')
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
export default registerAs('app', () => ({
|
export default registerAs('app', () => ({
|
||||||
port: parseInt(process.env.PORT || '3000', 10),
|
port: parseInt(process.env.PORT || '3000', 10),
|
||||||
database: {
|
database: {
|
||||||
@ -47,9 +38,6 @@ export default registerAs('app', () => ({
|
|||||||
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
|
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
|
||||||
tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10),
|
tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10),
|
||||||
},
|
},
|
||||||
cors: {
|
|
||||||
origins: parseCsv(process.env.BACKEND_CORS_ORIGINS),
|
|
||||||
},
|
|
||||||
auth: {
|
auth: {
|
||||||
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
|
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
|
||||||
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-in-production',
|
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-in-production',
|
||||||
|
|||||||
@ -5,36 +5,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
|||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import { DEV_JWT_SECRET, DEV_JWT_REFRESH_SECRET } from './config/configuration';
|
|
||||||
|
|
||||||
export type BackendRuntimeConfig = {
|
|
||||||
nodeEnv: string;
|
|
||||||
jwtSecret: string;
|
|
||||||
jwtRefreshSecret: string;
|
|
||||||
corsOrigins: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function assertSafeProductionConfig(config: BackendRuntimeConfig): void {
|
|
||||||
if (config.nodeEnv !== 'production') return;
|
|
||||||
|
|
||||||
if (!config.jwtSecret || config.jwtSecret === DEV_JWT_SECRET) {
|
|
||||||
throw new Error('JWT_SECRET must be set to a non-default value in production');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config.jwtRefreshSecret || config.jwtRefreshSecret === DEV_JWT_REFRESH_SECRET) {
|
|
||||||
throw new Error('JWT_REFRESH_SECRET must be set to a non-default value in production');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.corsOrigins.length === 0) {
|
|
||||||
throw new Error('BACKEND_CORS_ORIGINS must contain at least one origin in production');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildCorsOrigin(nodeEnv: string, corsOrigins: string[]): boolean | string[] {
|
|
||||||
return nodeEnv === 'production' ? corsOrigins : true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
@ -46,20 +17,7 @@ async function bootstrap() {
|
|||||||
app.useGlobalInterceptors(new TransformInterceptor());
|
app.useGlobalInterceptors(new TransformInterceptor());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
const configService = app.get(ConfigService);
|
app.enableCors({ origin: true, credentials: true });
|
||||||
const runtimeConfig: BackendRuntimeConfig = {
|
|
||||||
nodeEnv: process.env.NODE_ENV || 'development',
|
|
||||||
jwtSecret: configService.get<string>('app.auth.jwtSecret', ''),
|
|
||||||
jwtRefreshSecret: configService.get<string>('app.auth.jwtRefreshSecret', ''),
|
|
||||||
corsOrigins: configService.get<string[]>('app.cors.origins', []),
|
|
||||||
};
|
|
||||||
|
|
||||||
assertSafeProductionConfig(runtimeConfig);
|
|
||||||
|
|
||||||
app.enableCors({
|
|
||||||
origin: buildCorsOrigin(runtimeConfig.nodeEnv, runtimeConfig.corsOrigins),
|
|
||||||
credentials: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('MoexVibe API')
|
.setTitle('MoexVibe API')
|
||||||
@ -74,7 +32,4 @@ async function bootstrap() {
|
|||||||
console.log(`MoexVibe API running on http://localhost:${port}/api/v1`);
|
console.log(`MoexVibe API running on http://localhost:${port}/api/v1`);
|
||||||
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||||
}
|
}
|
||||||
|
bootstrap();
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
|
||||||
void bootstrap();
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,65 +0,0 @@
|
|||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { CacheService } from './cache.service';
|
|
||||||
|
|
||||||
describe('CacheService', () => {
|
|
||||||
const configService = {
|
|
||||||
get: vi.fn((_key: string, fallback?: unknown) => fallback),
|
|
||||||
} as unknown as ConfigService;
|
|
||||||
|
|
||||||
const createCache = () => ({
|
|
||||||
get: vi.fn(),
|
|
||||||
set: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores data with cachedAt metadata on cache miss', async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(new Date('2026-06-25T10:00:00.000Z'));
|
|
||||||
const cache = createCache();
|
|
||||||
cache.get.mockResolvedValue(undefined);
|
|
||||||
const service = new CacheService(cache as never, configService);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 1 }), 'ttlKey');
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
data: { value: 1 },
|
|
||||||
fromCache: false,
|
|
||||||
cachedAt: '2026-06-25T10:00:00.000Z',
|
|
||||||
});
|
|
||||||
expect(cache.set).toHaveBeenCalledWith(
|
|
||||||
'prefix:a',
|
|
||||||
{ data: { value: 1 }, cachedAt: '2026-06-25T10:00:00.000Z' },
|
|
||||||
900,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns cachedAt metadata on cache hit', async () => {
|
|
||||||
const cache = createCache();
|
|
||||||
cache.get.mockResolvedValue({
|
|
||||||
data: { value: 1 },
|
|
||||||
cachedAt: '2026-06-25T10:00:00.000Z',
|
|
||||||
});
|
|
||||||
const service = new CacheService(cache as never, configService);
|
|
||||||
|
|
||||||
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 2 }), 'ttlKey');
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
data: { value: 1 },
|
|
||||||
fromCache: true,
|
|
||||||
cachedAt: '2026-06-25T10:00:00.000Z',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports legacy raw cache values during rollout', async () => {
|
|
||||||
const cache = createCache();
|
|
||||||
cache.get.mockResolvedValue({ value: 1 });
|
|
||||||
const service = new CacheService(cache as never, configService);
|
|
||||||
|
|
||||||
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 2 }), 'ttlKey');
|
|
||||||
|
|
||||||
expect(result).toEqual({ data: { value: 1 }, fromCache: true, cachedAt: null });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
28
apps/backend/src/modules/cache/cache.service.ts
vendored
28
apps/backend/src/modules/cache/cache.service.ts
vendored
@ -3,11 +3,6 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
|||||||
import { Cache } from 'cache-manager';
|
import { Cache } from 'cache-manager';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
type CacheEntry<T> = {
|
|
||||||
data: T;
|
|
||||||
cachedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CacheService {
|
export class CacheService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -23,16 +18,6 @@ export class CacheService {
|
|||||||
await this.cacheManager.set(key, value, ttl);
|
await this.cacheManager.set(key, value, ttl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isCacheEntry<T>(value: unknown): value is CacheEntry<T> {
|
|
||||||
return (
|
|
||||||
typeof value === 'object' &&
|
|
||||||
value !== null &&
|
|
||||||
'data' in value &&
|
|
||||||
'cachedAt' in value &&
|
|
||||||
typeof (value as { cachedAt?: unknown }).cachedAt === 'string'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildKey(...parts: string[]): string {
|
private buildKey(...parts: string[]): string {
|
||||||
return parts.join(':');
|
return parts.join(':');
|
||||||
}
|
}
|
||||||
@ -46,19 +31,14 @@ export class CacheService {
|
|||||||
const key = this.buildKey(keyPrefix, ...keyParts);
|
const key = this.buildKey(keyPrefix, ...keyParts);
|
||||||
const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900);
|
const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900);
|
||||||
|
|
||||||
const cached = await this.get<CacheEntry<T> | T>(key);
|
const cached = await this.get<T>(key);
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
if (this.isCacheEntry<T>(cached)) {
|
return { data: cached, fromCache: true, cachedAt: null };
|
||||||
return { data: cached.data, fromCache: true, cachedAt: cached.cachedAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { data: cached as T, fromCache: true, cachedAt: null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await fetchFn();
|
const data = await fetchFn();
|
||||||
const cachedAt = new Date().toISOString();
|
await this.set(key, data, ttl);
|
||||||
await this.set(key, { data, cachedAt }, ttl);
|
|
||||||
|
|
||||||
return { data, fromCache: false, cachedAt };
|
return { data, fromCache: false, cachedAt: new Date().toISOString() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,43 +1,27 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { HealthService } from './health.service';
|
import { HealthService } from './health.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import configuration from '../../config/configuration';
|
||||||
|
|
||||||
describe('HealthService', () => {
|
describe('HealthService', () => {
|
||||||
let service: HealthService;
|
let service: HealthService;
|
||||||
const prisma = { $queryRaw: vi.fn() } as any;
|
const prisma = { $queryRaw: vi.fn() } as any;
|
||||||
const config = {
|
|
||||||
get: vi.fn((key: string, fallback?: unknown) => {
|
|
||||||
const values: Record<string, unknown> = {
|
|
||||||
'app.moex.baseUrl': 'https://iss.moex.test/iss',
|
|
||||||
'app.tbank.token': 'token-1',
|
|
||||||
};
|
|
||||||
|
|
||||||
return values[key] ?? fallback;
|
|
||||||
}),
|
|
||||||
} as unknown as ConfigService;
|
|
||||||
const fetchMock = vi.fn();
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
fetchMock.mockResolvedValue({ ok: true, status: 200 });
|
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [ConfigModule.forRoot({ load: [configuration], isGlobal: true })],
|
||||||
providers: [
|
providers: [
|
||||||
HealthService,
|
HealthService,
|
||||||
{ provide: PrismaService, useValue: prisma },
|
{ provide: PrismaService, useValue: prisma },
|
||||||
{ provide: ConfigService, useValue: config },
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<HealthService>(HealthService);
|
service = module.get<HealthService>(HealthService);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns ok when all dependencies are healthy', async () => {
|
it('returns ok when all dependencies are healthy', async () => {
|
||||||
prisma.$queryRaw.mockResolvedValue([{ 1: 1 }]);
|
prisma.$queryRaw.mockResolvedValue([{ 1: 1 }]);
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import {
|
|||||||
IsIn,
|
IsIn,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
IsDateString,
|
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
@ -32,7 +31,7 @@ export class AddPositionDto {
|
|||||||
|
|
||||||
@ApiProperty({ example: 10 })
|
@ApiProperty({ example: 10 })
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(0)
|
||||||
quantity!: number;
|
quantity!: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 250.5 })
|
@ApiPropertyOptional({ example: 250.5 })
|
||||||
@ -42,7 +41,7 @@ export class AddPositionDto {
|
|||||||
buyPrice?: number;
|
buyPrice?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '2026-06-01' })
|
@ApiPropertyOptional({ example: '2026-06-01' })
|
||||||
@IsDateString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
buyDate?: string;
|
buyDate?: string;
|
||||||
|
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { plainToInstance } from 'class-transformer';
|
|
||||||
import { validate } from 'class-validator';
|
|
||||||
import { AddPositionDto } from './add-position.dto';
|
|
||||||
import { UpdatePositionDto } from './update-position.dto';
|
|
||||||
|
|
||||||
describe('position DTO validation', () => {
|
|
||||||
const validateDto = async <T extends object>(cls: new () => T, payload: Record<string, unknown>) =>
|
|
||||||
validate(plainToInstance(cls, payload));
|
|
||||||
|
|
||||||
it('rejects zero quantity when adding a position', async () => {
|
|
||||||
const errors = await validateDto(AddPositionDto, { secid: 'SBER', quantity: 0 });
|
|
||||||
|
|
||||||
expect(errors.some((error) => error.property === 'quantity')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects zero quantity when updating a position', async () => {
|
|
||||||
const errors = await validateDto(UpdatePositionDto, { quantity: 0 });
|
|
||||||
|
|
||||||
expect(errors.some((error) => error.property === 'quantity')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid buyDate values', async () => {
|
|
||||||
const addErrors = await validateDto(AddPositionDto, {
|
|
||||||
secid: 'SBER',
|
|
||||||
quantity: 1,
|
|
||||||
buyDate: 'not-a-date',
|
|
||||||
});
|
|
||||||
const updateErrors = await validateDto(UpdatePositionDto, { buyDate: 'not-a-date' });
|
|
||||||
|
|
||||||
expect(addErrors.some((error) => error.property === 'buyDate')).toBe(true);
|
|
||||||
expect(updateErrors.some((error) => error.property === 'buyDate')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts valid position payloads', async () => {
|
|
||||||
await expect(
|
|
||||||
validateDto(AddPositionDto, { secid: 'SBER', quantity: 1, buyDate: '2026-06-01' }),
|
|
||||||
).resolves.toHaveLength(0);
|
|
||||||
await expect(
|
|
||||||
validateDto(UpdatePositionDto, { quantity: 2, buyDate: '2026-06-15' }),
|
|
||||||
).resolves.toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -7,7 +7,6 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsIn,
|
IsIn,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
IsDateString,
|
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
@ -25,7 +24,7 @@ const TAGS = [
|
|||||||
export class UpdatePositionDto {
|
export class UpdatePositionDto {
|
||||||
@ApiPropertyOptional({ example: 15 })
|
@ApiPropertyOptional({ example: 15 })
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(0)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
|
|
||||||
@ -36,7 +35,7 @@ export class UpdatePositionDto {
|
|||||||
buyPrice?: number;
|
buyPrice?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '2026-06-15' })
|
@ApiPropertyOptional({ example: '2026-06-15' })
|
||||||
@IsDateString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
buyDate?: string;
|
buyDate?: string;
|
||||||
|
|
||||||
|
|||||||
@ -19,12 +19,6 @@ export class BrokerAnalyticsDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
totalReceived!: number;
|
totalReceived!: number;
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
totalFees!: number;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
totalTaxesPaid!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ type: Number, nullable: true })
|
@ApiProperty({ type: Number, nullable: true })
|
||||||
totalReturnPercent!: number | null;
|
totalReturnPercent!: number | null;
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto
|
|||||||
import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto';
|
import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto';
|
||||||
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
|
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
|
||||||
import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
|
import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
|
||||||
import { BrokerPortfolioHistoryDataDto } from './broker-portfolio-history-response.dto';
|
|
||||||
|
|
||||||
export class BrokerAccountsEnvelopeDto {
|
export class BrokerAccountsEnvelopeDto {
|
||||||
@ApiProperty({ type: [BrokerAccountResponseDto] })
|
@ApiProperty({ type: [BrokerAccountResponseDto] })
|
||||||
@ -64,11 +63,3 @@ export class BrokerEventsEnvelopeDto {
|
|||||||
@ApiProperty({ type: ApiResponseMeta })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: ApiResponseMeta;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerPortfolioHistoryEnvelopeDto {
|
|
||||||
@ApiProperty({ type: BrokerPortfolioHistoryDataDto })
|
|
||||||
data!: BrokerPortfolioHistoryDataDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: ApiResponseMeta })
|
|
||||||
meta!: ApiResponseMeta;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -40,9 +40,4 @@ export class BrokerOperationQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
state?: string;
|
state?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Comma-separated category filter: trade,income,tax,fee,transfer,other' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
categories?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { BrokerMoneyDto } from './broker-money.dto';
|
|
||||||
|
|
||||||
export class BrokerPortfolioHistoryPointDto {
|
|
||||||
@ApiProperty()
|
|
||||||
month!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
label!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerMoneyDto })
|
|
||||||
value!: BrokerMoneyDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BrokerPortfolioHistoryDataDto {
|
|
||||||
@ApiProperty()
|
|
||||||
accountId!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ type: [BrokerPortfolioHistoryPointDto] })
|
|
||||||
points!: BrokerPortfolioHistoryPointDto[];
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
asOf!: string;
|
|
||||||
}
|
|
||||||
@ -4,7 +4,7 @@ import { CacheService } from '../../cache/cache.service';
|
|||||||
|
|
||||||
describe('BrokerAccountsService', () => {
|
describe('BrokerAccountsService', () => {
|
||||||
const client = {
|
const client = {
|
||||||
getUsersClient: vi.fn(),
|
getServiceClient: vi.fn(),
|
||||||
callUnary: vi.fn(),
|
callUnary: vi.fn(),
|
||||||
} as unknown as TBankClientService;
|
} as unknown as TBankClientService;
|
||||||
const cache = {
|
const cache = {
|
||||||
@ -23,7 +23,7 @@ describe('BrokerAccountsService', () => {
|
|||||||
cachedAt: '2026-06-16T02:30:00.000Z',
|
cachedAt: '2026-06-16T02:30:00.000Z',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
vi.mocked(client.getUsersClient).mockReturnValue({ getAccounts: vi.fn() } as any);
|
vi.mocked(client.getServiceClient).mockReturnValue({ getAccounts: vi.fn() } as any);
|
||||||
vi.mocked(client.callUnary).mockResolvedValue({
|
vi.mocked(client.callUnary).mockResolvedValue({
|
||||||
accounts: [
|
accounts: [
|
||||||
{ id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' },
|
{ id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' },
|
||||||
|
|||||||
@ -32,9 +32,9 @@ export class BrokerAccountsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async fetchAccounts(): Promise<BrokerAccount[]> {
|
private async fetchAccounts(): Promise<BrokerAccount[]> {
|
||||||
const usersClient = this.tbankClient.getUsersClient();
|
const usersClient = this.tbankClient.getServiceClient('UsersService') as any;
|
||||||
const response = await this.tbankClient.callUnary<
|
const response = await this.tbankClient.callUnary<
|
||||||
{ status: string },
|
Record<string, string>,
|
||||||
TBankAccountsResponse
|
TBankAccountsResponse
|
||||||
>(
|
>(
|
||||||
'UsersService/GetAccounts',
|
'UsersService/GetAccounts',
|
||||||
|
|||||||
@ -2,15 +2,11 @@ import { CacheService } from '../../cache/cache.service';
|
|||||||
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
||||||
import { BrokerAccountsService } from './broker-accounts.service';
|
import { BrokerAccountsService } from './broker-accounts.service';
|
||||||
import { BrokerAnalyticsService } from './broker-analytics.service';
|
import { BrokerAnalyticsService } from './broker-analytics.service';
|
||||||
import { TBankClientService } from './tbank-client.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import type { TBankOperationsByCursorResponse, TBankPortfolioResponse, TBankOperationItem } from '../types/tbank-proto.types';
|
|
||||||
|
|
||||||
describe('BrokerAnalyticsService', () => {
|
describe('BrokerAnalyticsService', () => {
|
||||||
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
||||||
const tbankClient = {
|
const prisma = { brokerOperation: { findMany: vi.fn() } } as unknown as PrismaService;
|
||||||
getOperationsClient: vi.fn(),
|
|
||||||
callUnary: vi.fn(),
|
|
||||||
} as unknown as TBankClientService;
|
|
||||||
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
||||||
|
|
||||||
const acc1 = {
|
const acc1 = {
|
||||||
@ -22,8 +18,6 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
accessLevel: null,
|
accessLevel: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockClient = { getPortfolio: vi.fn(), getOperationsByCursor: vi.fn() };
|
|
||||||
|
|
||||||
function mockCachePassthrough() {
|
function mockCachePassthrough() {
|
||||||
vi.mocked(cache.getOrFetch).mockImplementation(
|
vi.mocked(cache.getOrFetch).mockImplementation(
|
||||||
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
||||||
@ -34,55 +28,27 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockPortfolio(expectedYield?: { units?: number | string; nano?: number }): TBankPortfolioResponse {
|
function makeOp(type: string, value: number, state?: string | null) {
|
||||||
const response: TBankPortfolioResponse = { accountId: 'acc-1' };
|
return { type, payment: JSON.stringify({ value, currency: 'RUB' }), state } as any;
|
||||||
if (expectedYield) response.expectedYield = expectedYield;
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeItem(type: string, value: number, state = 'OPERATION_STATE_EXECUTED'): TBankOperationItem {
|
|
||||||
return {
|
|
||||||
type,
|
|
||||||
payment: { currency: 'RUB', units: Math.floor(Math.abs(value)), nano: Math.round((Math.abs(value) % 1) * 1e9) },
|
|
||||||
state,
|
|
||||||
id: `${type}-${value}`,
|
|
||||||
cursor: '',
|
|
||||||
brokerAccountId: 'acc-1',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockOpsResponse(items: TBankOperationItem[], hasNext = false, nextCursor = ''): TBankOperationsByCursorResponse {
|
|
||||||
return { items, hasNext, nextCursor };
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupMocks(ops: TBankOperationItem[], portfolio?: TBankPortfolioResponse) {
|
|
||||||
vi.mocked(tbankClient.callUnary).mockImplementation(
|
|
||||||
async (label: string) => {
|
|
||||||
if (label.includes('GetPortfolio')) return (portfolio ?? mockPortfolio()) as any;
|
|
||||||
if (label.includes('GetOperationsByCursor')) return mockOpsResponse(ops) as any;
|
|
||||||
throw new Error(`Unexpected call: ${label}`);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(tbankClient.getOperationsClient).mockReturnValue(mockClient as any);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws 404 for missing account', async () => {
|
it('throws 404 for missing account', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(null);
|
vi.mocked(accounts.findById).mockResolvedValue(null);
|
||||||
|
|
||||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
await expect(service.getAnalytics('missing')).rejects.toThrow(EntityNotFoundException);
|
await expect(service.getAnalytics('missing')).rejects.toThrow(EntityNotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns zeros for account with no operations', async () => {
|
it('returns zeros for account with no operations', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
mockCachePassthrough();
|
mockCachePassthrough();
|
||||||
setupMocks([]);
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
const result = await service.getAnalytics('acc-1');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(result.data).toEqual({
|
expect(result.data).toEqual({
|
||||||
@ -93,8 +59,6 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
totalCoupons: 0,
|
totalCoupons: 0,
|
||||||
totalReceived: 0,
|
totalReceived: 0,
|
||||||
totalReturnPercent: null,
|
totalReturnPercent: null,
|
||||||
totalFees: 0,
|
|
||||||
totalTaxesPaid: 0,
|
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -102,17 +66,17 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
it('aggregates deposit types correctly', async () => {
|
it('aggregates deposit types correctly', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
mockCachePassthrough();
|
mockCachePassthrough();
|
||||||
setupMocks([
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||||
makeItem('OPERATION_TYPE_INPUT', 1000),
|
makeOp('OPERATION_TYPE_INPUT', 1000),
|
||||||
makeItem('OPERATION_TYPE_INPUT_SWIFT', 500),
|
makeOp('OPERATION_TYPE_INPUT_SWIFT', 500),
|
||||||
makeItem('OPERATION_TYPE_INP_MULTI', 200),
|
makeOp('OPERATION_TYPE_INP_MULTI', 200),
|
||||||
makeItem('OPERATION_TYPE_OVER_PLACEMENT', 300),
|
makeOp('OPERATION_TYPE_OVER_PLACEMENT', 300),
|
||||||
makeItem('OPERATION_TYPE_TRANS_IIS_BS', 100),
|
makeOp('OPERATION_TYPE_TRANS_IIS_BS', 100),
|
||||||
makeItem('OPERATION_TYPE_TRANS_BS_BS', 50),
|
makeOp('OPERATION_TYPE_TRANS_BS_BS', 50),
|
||||||
makeItem('OPERATION_TYPE_INPUT_ACQUIRING', 150),
|
makeOp('OPERATION_TYPE_INPUT_ACQUIRING', 150),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
const result = await service.getAnalytics('acc-1');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(result.data.totalDeposits).toBe(2300);
|
expect(result.data.totalDeposits).toBe(2300);
|
||||||
@ -123,15 +87,15 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
it('aggregates withdrawal types with absolute value', async () => {
|
it('aggregates withdrawal types with absolute value', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
mockCachePassthrough();
|
mockCachePassthrough();
|
||||||
setupMocks([
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||||
makeItem('OPERATION_TYPE_OUTPUT', -500),
|
makeOp('OPERATION_TYPE_OUTPUT', -500),
|
||||||
makeItem('OPERATION_TYPE_OUTPUT_SWIFT', -200),
|
makeOp('OPERATION_TYPE_OUTPUT_SWIFT', -200),
|
||||||
makeItem('OPERATION_TYPE_OUTPUT_ACQUIRING', -100),
|
makeOp('OPERATION_TYPE_OUTPUT_ACQUIRING', -100),
|
||||||
makeItem('OPERATION_TYPE_OUT_MULTI', -50),
|
makeOp('OPERATION_TYPE_OUT_MULTI', -50),
|
||||||
makeItem('OPERATION_TYPE_INPUT', 1000),
|
makeOp('OPERATION_TYPE_INPUT', 1000),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
const result = await service.getAnalytics('acc-1');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(result.data.totalDeposits).toBe(1000);
|
expect(result.data.totalDeposits).toBe(1000);
|
||||||
@ -142,14 +106,14 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
it('aggregates dividend and coupon types', async () => {
|
it('aggregates dividend and coupon types', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
mockCachePassthrough();
|
mockCachePassthrough();
|
||||||
setupMocks([
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||||
makeItem('OPERATION_TYPE_DIVIDEND', 300),
|
makeOp('OPERATION_TYPE_DIVIDEND', 300),
|
||||||
makeItem('OPERATION_TYPE_DIV_EXT', 150),
|
makeOp('OPERATION_TYPE_DIV_EXT', 150),
|
||||||
makeItem('OPERATION_TYPE_COUPON', 75),
|
makeOp('OPERATION_TYPE_COUPON', 75),
|
||||||
makeItem('OPERATION_TYPE_COUPON', 25),
|
makeOp('OPERATION_TYPE_COUPON', 25),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
const result = await service.getAnalytics('acc-1');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(result.data.totalDividends).toBe(450);
|
expect(result.data.totalDividends).toBe(450);
|
||||||
@ -157,64 +121,82 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
expect(result.data.totalReceived).toBe(550);
|
expect(result.data.totalReceived).toBe(550);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses expectedYield from portfolio for totalReturnPercent', async () => {
|
it('calculates totalReturnPercent correctly', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
mockCachePassthrough();
|
mockCachePassthrough();
|
||||||
setupMocks(
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||||
[
|
makeOp('OPERATION_TYPE_INPUT', 10000),
|
||||||
makeItem('OPERATION_TYPE_INPUT', 10000),
|
makeOp('OPERATION_TYPE_DIVIDEND', 500),
|
||||||
makeItem('OPERATION_TYPE_DIVIDEND', 500),
|
makeOp('OPERATION_TYPE_COUPON', 200),
|
||||||
makeItem('OPERATION_TYPE_COUPON', 200),
|
]);
|
||||||
],
|
|
||||||
mockPortfolio({ units: 7, nano: 0 }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
const result = await service.getAnalytics('acc-1');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
|
expect(result.data.netInvested).toBe(10000);
|
||||||
|
expect(result.data.totalReceived).toBe(700);
|
||||||
expect(result.data.totalReturnPercent).toBe(7);
|
expect(result.data.totalReturnPercent).toBe(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null totalReturnPercent when portfolio has no expectedYield', async () => {
|
it('returns null totalReturnPercent when netInvested <= 0', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
mockCachePassthrough();
|
mockCachePassthrough();
|
||||||
setupMocks([makeItem('OPERATION_TYPE_OUTPUT', -500)]);
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||||
|
makeOp('OPERATION_TYPE_OUTPUT', -500),
|
||||||
|
makeOp('OPERATION_TYPE_DIVIDEND', 100),
|
||||||
|
]);
|
||||||
|
|
||||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
const result = await service.getAnalytics('acc-1');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
|
expect(result.data.netInvested).toBe(-500);
|
||||||
expect(result.data.totalReturnPercent).toBeNull();
|
expect(result.data.totalReturnPercent).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aggregates fee and tax categories from operations', async () => {
|
it('handles malformed payment JSON gracefully', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
mockCachePassthrough();
|
mockCachePassthrough();
|
||||||
setupMocks([
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||||
makeItem('OPERATION_TYPE_SERVICE_FEE', -100),
|
{ type: 'OPERATION_TYPE_INPUT', payment: 'invalid-json', state: 'OPERATION_STATE_EXECUTED' },
|
||||||
makeItem('OPERATION_TYPE_BROKER_FEE', -50),
|
{ type: 'OPERATION_TYPE_INPUT', payment: JSON.stringify({ value: 500, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED' },
|
||||||
makeItem('OPERATION_TYPE_TAX', -200),
|
] as any);
|
||||||
makeItem('OPERATION_TYPE_DIVIDEND_TAX', -30),
|
|
||||||
makeItem('OPERATION_TYPE_INPUT', 1000),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
const result = await service.getAnalytics('acc-1');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(result.data.totalDeposits).toBe(1000);
|
expect(result.data.totalDeposits).toBe(500);
|
||||||
expect(result.data.totalFees).toBe(150);
|
});
|
||||||
expect(result.data.totalTaxesPaid).toBe(230);
|
|
||||||
expect(result.data.netInvested).toBe(1000);
|
it('ignores non-executed and non-null state operations', async () => {
|
||||||
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
|
mockCachePassthrough();
|
||||||
|
|
||||||
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
|
await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
|
expect(prisma.brokerOperation.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
accountId: 'acc-1',
|
||||||
|
type: { in: expect.any(Array) },
|
||||||
|
payment: { not: null },
|
||||||
|
OR: [
|
||||||
|
{ state: 'OPERATION_STATE_EXECUTED' },
|
||||||
|
{ state: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { type: true, payment: true },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rounds all monetary values to 2 decimal places', async () => {
|
it('rounds all monetary values to 2 decimal places', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
||||||
mockCachePassthrough();
|
mockCachePassthrough();
|
||||||
setupMocks([
|
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
|
||||||
makeItem('OPERATION_TYPE_INPUT', 100.336),
|
makeOp('OPERATION_TYPE_INPUT', 100.336),
|
||||||
makeItem('OPERATION_TYPE_DIVIDEND', 50.789),
|
makeOp('OPERATION_TYPE_DIVIDEND', 50.789),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
const result = await service.getAnalytics('acc-1');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(result.data.totalDeposits).toBe(100.34);
|
expect(result.data.totalDeposits).toBe(100.34);
|
||||||
@ -234,47 +216,17 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
totalCoupons: 0,
|
totalCoupons: 0,
|
||||||
totalReceived: 0,
|
totalReceived: 0,
|
||||||
totalReturnPercent: null,
|
totalReturnPercent: null,
|
||||||
totalFees: 0,
|
|
||||||
totalTaxesPaid: 0,
|
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
},
|
},
|
||||||
fromCache: true,
|
fromCache: true,
|
||||||
cachedAt: '2026-06-24T10:00:00.000Z',
|
cachedAt: '2026-06-24T10:00:00.000Z',
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
const result = await service.getAnalytics('acc-1');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(result.data.netInvested).toBe(1000);
|
expect(result.data.netInvested).toBe(1000);
|
||||||
expect(result.fromCache).toBe(true);
|
expect(result.fromCache).toBe(true);
|
||||||
expect(result.cachedAt).toBe('2026-06-24T10:00:00.000Z');
|
expect(result.cachedAt).toBe('2026-06-24T10:00:00.000Z');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('paginates through multiple pages of operations', async () => {
|
|
||||||
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
||||||
mockCachePassthrough();
|
|
||||||
|
|
||||||
let callCount = 0;
|
|
||||||
vi.mocked(tbankClient.callUnary).mockImplementation(
|
|
||||||
async (label: string) => {
|
|
||||||
if (label.includes('GetPortfolio')) return mockPortfolio({ units: 5, nano: 0 }) as any;
|
|
||||||
if (label.includes('GetOperationsByCursor')) {
|
|
||||||
callCount++;
|
|
||||||
if (callCount === 1) {
|
|
||||||
return mockOpsResponse([makeItem('OPERATION_TYPE_INPUT', 1000)], true, 'cursor-1') as any;
|
|
||||||
}
|
|
||||||
return mockOpsResponse([makeItem('OPERATION_TYPE_DIVIDEND', 500)], false, '') as any;
|
|
||||||
}
|
|
||||||
throw new Error(`Unexpected call: ${label}`);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
|
|
||||||
const result = await service.getAnalytics('acc-1');
|
|
||||||
|
|
||||||
expect(result.data.totalDeposits).toBe(1000);
|
|
||||||
expect(result.data.totalDividends).toBe(500);
|
|
||||||
expect(result.data.totalReceived).toBe(500);
|
|
||||||
expect(callCount).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,14 +1,11 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { CacheService } from '../../cache/cache.service';
|
import { CacheService } from '../../cache/cache.service';
|
||||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||||
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
|
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
|
||||||
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
||||||
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
||||||
import { BrokerAccountsService } from './broker-accounts.service';
|
import { BrokerAccountsService } from './broker-accounts.service';
|
||||||
import { TBankClientService, type TBankPortfolioRequest } from './tbank-client.service';
|
|
||||||
import type { BrokerOperation } from '../types/broker.types';
|
|
||||||
import { mapOperationsPage } from '../mappers/operation.mapper';
|
|
||||||
import type { TBankOperationsByCursorResponse, TBankPortfolioResponse } from '../types/tbank-proto.types';
|
|
||||||
|
|
||||||
const DEPOSIT_TYPES = new Set([
|
const DEPOSIT_TYPES = new Set([
|
||||||
'OPERATION_TYPE_INPUT',
|
'OPERATION_TYPE_INPUT',
|
||||||
@ -31,15 +28,18 @@ const DIVIDEND_TYPES = new Set(['OPERATION_TYPE_DIVIDEND', 'OPERATION_TYPE_DIV_E
|
|||||||
|
|
||||||
const COUPON_TYPES = new Set(['OPERATION_TYPE_COUPON']);
|
const COUPON_TYPES = new Set(['OPERATION_TYPE_COUPON']);
|
||||||
|
|
||||||
const MAX_FETCH_PAGES = 50;
|
const ANALYTICS_TYPES = new Set([
|
||||||
|
...DEPOSIT_TYPES,
|
||||||
|
...WITHDRAWAL_TYPES,
|
||||||
|
...DIVIDEND_TYPES,
|
||||||
|
...COUPON_TYPES,
|
||||||
|
]);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrokerAnalyticsService {
|
export class BrokerAnalyticsService {
|
||||||
private readonly logger = new Logger(BrokerAnalyticsService.name);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
private readonly accountsService: BrokerAccountsService,
|
private readonly accountsService: BrokerAccountsService,
|
||||||
private readonly tbankClient: TBankClientService,
|
|
||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -58,26 +58,34 @@ export class BrokerAnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {
|
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {
|
||||||
const [portfolio, allOperations] = await Promise.all([
|
const operations = await this.prisma.brokerOperation.findMany({
|
||||||
this.fetchPortfolio(accountId),
|
where: {
|
||||||
this.fetchAllOperations(accountId),
|
accountId,
|
||||||
]);
|
type: { in: Array.from(ANALYTICS_TYPES) },
|
||||||
|
payment: { not: null },
|
||||||
|
OR: [
|
||||||
|
{ state: 'OPERATION_STATE_EXECUTED' },
|
||||||
|
{ state: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { type: true, payment: true },
|
||||||
|
});
|
||||||
|
|
||||||
let totalDeposits = 0;
|
let totalDeposits = 0;
|
||||||
let totalWithdrawn = 0;
|
let totalWithdrawn = 0;
|
||||||
let totalDividends = 0;
|
let totalDividends = 0;
|
||||||
let totalCoupons = 0;
|
let totalCoupons = 0;
|
||||||
let totalFees = 0;
|
|
||||||
let totalTaxesPaid = 0;
|
|
||||||
|
|
||||||
for (const op of allOperations) {
|
for (const op of operations) {
|
||||||
const value = op.payment?.value ?? 0;
|
let value = 0;
|
||||||
|
try {
|
||||||
|
const payment = JSON.parse(op.payment!);
|
||||||
|
value = payment.value ?? 0;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (op.category === 'fee') {
|
if (DEPOSIT_TYPES.has(op.type)) {
|
||||||
totalFees += Math.abs(value);
|
|
||||||
} else if (op.category === 'tax') {
|
|
||||||
totalTaxesPaid += Math.abs(value);
|
|
||||||
} else if (DEPOSIT_TYPES.has(op.type)) {
|
|
||||||
totalDeposits += value;
|
totalDeposits += value;
|
||||||
} else if (WITHDRAWAL_TYPES.has(op.type)) {
|
} else if (WITHDRAWAL_TYPES.has(op.type)) {
|
||||||
totalWithdrawn += Math.abs(value);
|
totalWithdrawn += Math.abs(value);
|
||||||
@ -90,11 +98,8 @@ export class BrokerAnalyticsService {
|
|||||||
|
|
||||||
const netInvested = totalDeposits - totalWithdrawn;
|
const netInvested = totalDeposits - totalWithdrawn;
|
||||||
const totalReceived = totalDividends + totalCoupons;
|
const totalReceived = totalDividends + totalCoupons;
|
||||||
|
const totalReturnPercent =
|
||||||
const portfolioYield = portfolio.expectedYield;
|
netInvested > 0 ? Math.round((totalReceived / netInvested) * 10000) / 100 : null;
|
||||||
const expectedYieldPercent = portfolioYield
|
|
||||||
? Math.round((Number(portfolioYield.units ?? 0) + (portfolioYield.nano ?? 0) / 1e9) * 100) / 100
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalDeposits: Math.round(totalDeposits * 100) / 100,
|
totalDeposits: Math.round(totalDeposits * 100) / 100,
|
||||||
@ -103,66 +108,8 @@ export class BrokerAnalyticsService {
|
|||||||
totalDividends: Math.round(totalDividends * 100) / 100,
|
totalDividends: Math.round(totalDividends * 100) / 100,
|
||||||
totalCoupons: Math.round(totalCoupons * 100) / 100,
|
totalCoupons: Math.round(totalCoupons * 100) / 100,
|
||||||
totalReceived: Math.round(totalReceived * 100) / 100,
|
totalReceived: Math.round(totalReceived * 100) / 100,
|
||||||
totalFees: Math.round(totalFees * 100) / 100,
|
totalReturnPercent,
|
||||||
totalTaxesPaid: Math.round(totalTaxesPaid * 100) / 100,
|
|
||||||
totalReturnPercent: expectedYieldPercent,
|
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchPortfolio(accountId: string): Promise<TBankPortfolioResponse> {
|
|
||||||
const operationsClient = this.tbankClient.getOperationsClient();
|
|
||||||
const response = await this.tbankClient.callUnary<
|
|
||||||
TBankPortfolioRequest,
|
|
||||||
TBankPortfolioResponse
|
|
||||||
>(
|
|
||||||
'OperationsService/GetPortfolio',
|
|
||||||
operationsClient.getPortfolio.bind(operationsClient),
|
|
||||||
{ accountId, currency: 'RUB' },
|
|
||||||
);
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchAllOperations(accountId: string): Promise<BrokerOperation[]> {
|
|
||||||
const allOps: BrokerOperation[] = [];
|
|
||||||
let cursor: string | undefined;
|
|
||||||
let pageCount = 0;
|
|
||||||
|
|
||||||
do {
|
|
||||||
if (pageCount >= MAX_FETCH_PAGES) {
|
|
||||||
this.logger.warn(`Reached max fetch pages (${MAX_FETCH_PAGES}) for account ${accountId}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request: Record<string, unknown> = {
|
|
||||||
accountId,
|
|
||||||
state: 'OPERATION_STATE_EXECUTED',
|
|
||||||
limit: 1000,
|
|
||||||
withoutCommissions: false,
|
|
||||||
withoutTrades: false,
|
|
||||||
withoutOvernights: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (cursor) request.cursor = cursor;
|
|
||||||
|
|
||||||
const operationsClient = this.tbankClient.getOperationsClient();
|
|
||||||
const response = await this.tbankClient.callUnary<
|
|
||||||
Record<string, unknown>,
|
|
||||||
TBankOperationsByCursorResponse
|
|
||||||
>(
|
|
||||||
'OperationsService/GetOperationsByCursor',
|
|
||||||
operationsClient.getOperationsByCursor.bind(operationsClient),
|
|
||||||
request,
|
|
||||||
);
|
|
||||||
|
|
||||||
const page = mapOperationsPage(accountId, response);
|
|
||||||
allOps.push(...page.items);
|
|
||||||
pageCount++;
|
|
||||||
|
|
||||||
cursor = page.hasNext ? (page.nextCursor ?? undefined) : undefined;
|
|
||||||
} while (cursor);
|
|
||||||
|
|
||||||
return allOps;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export class BrokerInstrumentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async fetchByUid(instrumentUid: string): Promise<TBankInstrument | null> {
|
private async fetchByUid(instrumentUid: string): Promise<TBankInstrument | null> {
|
||||||
const instrumentsClient = this.tbankClient.getInstrumentsClient();
|
const instrumentsClient = this.tbankClient.getServiceClient('InstrumentsService') as any;
|
||||||
const response = await this.tbankClient.callUnary<
|
const response = await this.tbankClient.callUnary<
|
||||||
{ idType: string; id: string },
|
{ idType: string; id: string },
|
||||||
TBankInstrumentResponse
|
TBankInstrumentResponse
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { TBankClientService } from './tbank-client.service';
|
|||||||
|
|
||||||
describe('BrokerOperationsService', () => {
|
describe('BrokerOperationsService', () => {
|
||||||
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
||||||
const client = { getOperationsClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
|
const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
|
||||||
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -36,7 +36,7 @@ describe('BrokerOperationsService', () => {
|
|||||||
cachedAt: null,
|
cachedAt: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
vi.mocked(client.getOperationsClient).mockReturnValue({ getOperationsByCursor: vi.fn() } as any);
|
vi.mocked(client.getServiceClient).mockReturnValue({ getOperationsByCursor: vi.fn() } as any);
|
||||||
vi.mocked(client.callUnary).mockResolvedValue({
|
vi.mocked(client.callUnary).mockResolvedValue({
|
||||||
hasNext: false,
|
hasNext: false,
|
||||||
items: [{ cursor: 'c1', brokerAccountId: 'acc-1', type: 'OPERATION_TYPE_BUY' }],
|
items: [{ cursor: 'c1', brokerAccountId: 'acc-1', type: 'OPERATION_TYPE_BUY' }],
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto'
|
|||||||
import { mapOperationsPage } from '../mappers/operation.mapper';
|
import { mapOperationsPage } from '../mappers/operation.mapper';
|
||||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||||
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
||||||
import type { BrokerOperation, BrokerOperationsPage } from '../types/broker.types';
|
import type { BrokerOperationsPage } from '../types/broker.types';
|
||||||
import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types';
|
import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types';
|
||||||
import { BrokerAccountsService } from './broker-accounts.service';
|
import { BrokerAccountsService } from './broker-accounts.service';
|
||||||
import { TBankClientService } from './tbank-client.service';
|
import { TBankClientService } from './tbank-client.service';
|
||||||
@ -33,19 +33,6 @@ export class BrokerOperationsService {
|
|||||||
'tbankOperationsTtl',
|
'tbankOperationsTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (query.categories) {
|
|
||||||
const allowedCategories = query.categories
|
|
||||||
.split(',')
|
|
||||||
.map((c) => c.trim() as BrokerOperation['category'])
|
|
||||||
.filter((c) => ['trade', 'income', 'tax', 'fee', 'transfer', 'other'].includes(c));
|
|
||||||
|
|
||||||
if (allowedCategories.length > 0) {
|
|
||||||
result.data.items = result.data.items.filter((item) =>
|
|
||||||
allowedCategories.includes(item.category),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,7 +69,7 @@ export class BrokerOperationsService {
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
request: Record<string, unknown>,
|
request: Record<string, unknown>,
|
||||||
): Promise<BrokerOperationsPage> {
|
): Promise<BrokerOperationsPage> {
|
||||||
const operationsClient = this.tbankClient.getOperationsClient();
|
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
|
||||||
const response = await this.tbankClient.callUnary<
|
const response = await this.tbankClient.callUnary<
|
||||||
Record<string, unknown>,
|
Record<string, unknown>,
|
||||||
TBankOperationsByCursorResponse
|
TBankOperationsByCursorResponse
|
||||||
|
|||||||
@ -1,72 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { CacheService } from '../../cache/cache.service';
|
|
||||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
|
||||||
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
|
||||||
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
|
||||||
import { BrokerAccountsService } from './broker-accounts.service';
|
|
||||||
import { BrokerPortfolioService } from './broker-portfolio.service';
|
|
||||||
import type { BrokerPortfolioHistoryDataDto, BrokerPortfolioHistoryPointDto } from '../dto/broker-portfolio-history-response.dto';
|
|
||||||
import type { BrokerMoney } from '../types/broker.types';
|
|
||||||
|
|
||||||
const RUSSIAN_MONTHS = [
|
|
||||||
'Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн',
|
|
||||||
'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек',
|
|
||||||
];
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class BrokerPortfolioHistoryService {
|
|
||||||
constructor(
|
|
||||||
private readonly accountsService: BrokerAccountsService,
|
|
||||||
private readonly portfolioService: BrokerPortfolioService,
|
|
||||||
private readonly cacheService: CacheService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async getHistory(
|
|
||||||
accountId: string,
|
|
||||||
months: number,
|
|
||||||
): Promise<ApiEnvelopePayload<BrokerPortfolioHistoryDataDto>> {
|
|
||||||
const account = await this.accountsService.findById(accountId);
|
|
||||||
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId);
|
|
||||||
|
|
||||||
const result = await this.cacheService.getOrFetch(
|
|
||||||
TBANK_CACHE_KEYS.portfolio,
|
|
||||||
[accountId, 'history', String(months)],
|
|
||||||
() => this.computeHistory(accountId, months),
|
|
||||||
'tbankPortfolioTtl',
|
|
||||||
);
|
|
||||||
|
|
||||||
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async computeHistory(
|
|
||||||
accountId: string,
|
|
||||||
months: number,
|
|
||||||
): Promise<BrokerPortfolioHistoryDataDto> {
|
|
||||||
const portfolioEnvelope = await this.portfolioService.getPortfolio(accountId);
|
|
||||||
const currentValue = portfolioEnvelope.data.totals.portfolio;
|
|
||||||
|
|
||||||
const defaultMoney: BrokerMoney = currentValue ?? {
|
|
||||||
currency: 'RUB',
|
|
||||||
units: '0',
|
|
||||||
nano: 0,
|
|
||||||
value: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const points: BrokerPortfolioHistoryPointDto[] = [];
|
|
||||||
|
|
||||||
for (let i = months - 1; i >= 0; i--) {
|
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
|
||||||
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
||||||
const label = RUSSIAN_MONTHS[d.getMonth()];
|
|
||||||
|
|
||||||
points.push({ month, label, value: { ...defaultMoney } });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
accountId,
|
|
||||||
points,
|
|
||||||
asOf: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -8,7 +8,7 @@ import { TBankClientService } from './tbank-client.service';
|
|||||||
describe('BrokerPortfolioService', () => {
|
describe('BrokerPortfolioService', () => {
|
||||||
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
||||||
const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService;
|
const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService;
|
||||||
const client = { getOperationsClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
|
const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
|
||||||
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -38,7 +38,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
cachedAt: null,
|
cachedAt: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
vi.mocked(client.getOperationsClient).mockReturnValue({
|
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||||
getPortfolio: vi.fn(),
|
getPortfolio: vi.fn(),
|
||||||
getPositions: vi.fn(),
|
getPositions: vi.fn(),
|
||||||
} as any);
|
} as any);
|
||||||
@ -104,7 +104,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
it('returns first page of positions', async () => {
|
it('returns first page of positions', async () => {
|
||||||
mockAccount();
|
mockAccount();
|
||||||
mockCache();
|
mockCache();
|
||||||
vi.mocked(client.getOperationsClient).mockReturnValue({
|
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||||
getPortfolio: vi.fn(),
|
getPortfolio: vi.fn(),
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
@ -139,7 +139,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
it('paginates using cursor', async () => {
|
it('paginates using cursor', async () => {
|
||||||
mockAccount();
|
mockAccount();
|
||||||
mockCache();
|
mockCache();
|
||||||
vi.mocked(client.getOperationsClient).mockReturnValue({
|
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||||
getPortfolio: vi.fn(),
|
getPortfolio: vi.fn(),
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
@ -179,7 +179,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
it('returns last page with hasNext=false', async () => {
|
it('returns last page with hasNext=false', async () => {
|
||||||
mockAccount();
|
mockAccount();
|
||||||
mockCache();
|
mockCache();
|
||||||
vi.mocked(client.getOperationsClient).mockReturnValue({
|
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||||
getPortfolio: vi.fn(),
|
getPortfolio: vi.fn(),
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
@ -206,7 +206,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
it('caches positions with cursor/limit/type in key and tbankPositionsTtl', async () => {
|
it('caches positions with cursor/limit/type in key and tbankPositionsTtl', async () => {
|
||||||
mockAccount();
|
mockAccount();
|
||||||
mockCache();
|
mockCache();
|
||||||
vi.mocked(client.getOperationsClient).mockReturnValue({
|
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||||
getPortfolio: vi.fn(),
|
getPortfolio: vi.fn(),
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
@ -229,7 +229,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
it('filters by instrument type and caches with type in key', async () => {
|
it('filters by instrument type and caches with type in key', async () => {
|
||||||
mockAccount();
|
mockAccount();
|
||||||
mockCache();
|
mockCache();
|
||||||
vi.mocked(client.getOperationsClient).mockReturnValue({
|
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||||
getPortfolio: vi.fn(),
|
getPortfolio: vi.fn(),
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
@ -279,7 +279,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
it('returns empty items when type filter matches nothing', async () => {
|
it('returns empty items when type filter matches nothing', async () => {
|
||||||
mockAccount();
|
mockAccount();
|
||||||
mockCache();
|
mockCache();
|
||||||
vi.mocked(client.getOperationsClient).mockReturnValue({
|
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||||
getPortfolio: vi.fn(),
|
getPortfolio: vi.fn(),
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
|
|||||||
@ -120,7 +120,7 @@ export class BrokerPortfolioService {
|
|||||||
'tbank:raw-portfolio',
|
'tbank:raw-portfolio',
|
||||||
[accountId],
|
[accountId],
|
||||||
async () => {
|
async () => {
|
||||||
const operationsClient = this.tbankClient.getOperationsClient();
|
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
|
||||||
return this.tbankClient.callUnary<
|
return this.tbankClient.callUnary<
|
||||||
{ accountId: string; currency: string },
|
{ accountId: string; currency: string },
|
||||||
TBankPortfolioResponse
|
TBankPortfolioResponse
|
||||||
@ -139,7 +139,7 @@ export class BrokerPortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async fetchPositions(accountId: string): Promise<TBankPositionsResponse> {
|
private async fetchPositions(accountId: string): Promise<TBankPositionsResponse> {
|
||||||
const operationsClient = this.tbankClient.getOperationsClient();
|
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
|
||||||
return this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>(
|
return this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>(
|
||||||
'OperationsService/GetPositions',
|
'OperationsService/GetPositions',
|
||||||
operationsClient.getPositions.bind(operationsClient),
|
operationsClient.getPositions.bind(operationsClient),
|
||||||
|
|||||||
@ -44,16 +44,6 @@ describe('TBankClientService', () => {
|
|||||||
expect(() => service.getServiceClient('UsersService')).not.toThrow();
|
expect(() => service.getServiceClient('UsersService')).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes typed service-client facades for broker services', () => {
|
|
||||||
const service = new TBankClientService(config);
|
|
||||||
|
|
||||||
expect(service.getUsersClient()).toHaveProperty('getAccounts');
|
|
||||||
expect(service.getOperationsClient()).toHaveProperty('getPortfolio');
|
|
||||||
expect(service.getOperationsClient()).toHaveProperty('getPositions');
|
|
||||||
expect(service.getOperationsClient()).toHaveProperty('getOperationsByCursor');
|
|
||||||
expect(service.getInstrumentsClient()).toHaveProperty('getInstrumentBy');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates grpc SSL credentials with configured custom CA certificate', () => {
|
it('creates grpc SSL credentials with configured custom CA certificate', () => {
|
||||||
const caPath = join(mkdtempSync(join(tmpdir(), 'tbank-ca-')), 'root.pem');
|
const caPath = join(mkdtempSync(join(tmpdir(), 'tbank-ca-')), 'root.pem');
|
||||||
writeFileSync(caPath, '-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----\n');
|
writeFileSync(caPath, '-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----\n');
|
||||||
|
|||||||
@ -1,13 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { TBankNotConfiguredException, TBankApiException } from '../../../common/exceptions/tbank-api.exception';
|
import { TBankNotConfiguredException, TBankApiException } from '../../../common/exceptions/tbank-api.exception';
|
||||||
import type {
|
|
||||||
TBankAccountsResponse,
|
|
||||||
TBankInstrumentResponse,
|
|
||||||
TBankOperationsByCursorResponse,
|
|
||||||
TBankPortfolioResponse,
|
|
||||||
TBankPositionsResponse,
|
|
||||||
} from '../types/tbank-proto.types';
|
|
||||||
import {
|
import {
|
||||||
CallOptions,
|
CallOptions,
|
||||||
ChannelCredentials,
|
ChannelCredentials,
|
||||||
@ -33,25 +26,6 @@ type GrpcUnary<TRequest, TResponse> = (
|
|||||||
|
|
||||||
type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client;
|
type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client;
|
||||||
|
|
||||||
type TBankAccountsRequest = { status: string };
|
|
||||||
export type TBankPortfolioRequest = { accountId: string; currency: string };
|
|
||||||
type TBankPositionsRequest = { accountId: string };
|
|
||||||
type TBankInstrumentRequest = { idType: string; id: string };
|
|
||||||
|
|
||||||
export type TBankUsersClient = Client & {
|
|
||||||
getAccounts: GrpcUnary<TBankAccountsRequest, TBankAccountsResponse>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TBankOperationsClient = Client & {
|
|
||||||
getPortfolio: GrpcUnary<TBankPortfolioRequest, TBankPortfolioResponse>;
|
|
||||||
getPositions: GrpcUnary<TBankPositionsRequest, TBankPositionsResponse>;
|
|
||||||
getOperationsByCursor: GrpcUnary<Record<string, unknown>, TBankOperationsByCursorResponse>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TBankInstrumentsClient = Client & {
|
|
||||||
getInstrumentBy: GrpcUnary<TBankInstrumentRequest, TBankInstrumentResponse>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type QueueName = 'operations' | 'instruments' | 'users';
|
type QueueName = 'operations' | 'instruments' | 'users';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -146,18 +120,6 @@ export class TBankClientService {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
getUsersClient(): TBankUsersClient {
|
|
||||||
return this.getServiceClient('UsersService') as TBankUsersClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
getOperationsClient(): TBankOperationsClient {
|
|
||||||
return this.getServiceClient('OperationsService') as TBankOperationsClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
getInstrumentsClient(): TBankInstrumentsClient {
|
|
||||||
return this.getServiceClient('InstrumentsService') as TBankInstrumentsClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
async callUnary<TRequest, TResponse>(
|
async callUnary<TRequest, TResponse>(
|
||||||
label: string,
|
label: string,
|
||||||
method: GrpcUnary<TRequest, TResponse>,
|
method: GrpcUnary<TRequest, TResponse>,
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { BrokerAnalyticsService } from './services/broker-analytics.service';
|
|||||||
import { BrokerEventsService } from './services/broker-events.service';
|
import { BrokerEventsService } from './services/broker-events.service';
|
||||||
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
||||||
import { BrokerOperationsService } from './services/broker-operations.service';
|
import { BrokerOperationsService } from './services/broker-operations.service';
|
||||||
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
|
|
||||||
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||||
|
|
||||||
describe('TBankController', () => {
|
describe('TBankController', () => {
|
||||||
@ -16,7 +15,6 @@ describe('TBankController', () => {
|
|||||||
const events = { getEvents: vi.fn() } as unknown as BrokerEventsService;
|
const events = { getEvents: vi.fn() } as unknown as BrokerEventsService;
|
||||||
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
|
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
|
||||||
const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService;
|
const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService;
|
||||||
const portfolioHistory = { getHistory: vi.fn() } as unknown as BrokerPortfolioHistoryService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@ -44,7 +42,7 @@ describe('TBankController', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
|
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||||
const response = await controller.getAccounts();
|
const response = await controller.getAccounts();
|
||||||
|
|
||||||
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
||||||
@ -56,7 +54,7 @@ describe('TBankController', () => {
|
|||||||
it('exposes a sync trigger for durable operation history', async () => {
|
it('exposes a sync trigger for durable operation history', async () => {
|
||||||
vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 });
|
vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 });
|
||||||
|
|
||||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
|
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||||
const response = await controller.syncOperations('acc-1', {
|
const response = await controller.syncOperations('acc-1', {
|
||||||
from: '2026-06-01T00:00:00.000Z',
|
from: '2026-06-01T00:00:00.000Z',
|
||||||
to: '2026-06-17T00:00:00.000Z',
|
to: '2026-06-17T00:00:00.000Z',
|
||||||
@ -78,15 +76,13 @@ describe('TBankController', () => {
|
|||||||
totalCoupons: 50,
|
totalCoupons: 50,
|
||||||
totalReceived: 200,
|
totalReceived: 200,
|
||||||
totalReturnPercent: 25,
|
totalReturnPercent: 25,
|
||||||
totalFees: 0,
|
|
||||||
totalTaxesPaid: 0,
|
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
};
|
};
|
||||||
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce(
|
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce(
|
||||||
new ApiEnvelopePayload(analyticsData, false, null),
|
new ApiEnvelopePayload(analyticsData, false, null),
|
||||||
);
|
);
|
||||||
|
|
||||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
|
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||||
const response = await controller.getAnalytics('acc-1');
|
const response = await controller.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1');
|
expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1');
|
||||||
@ -96,67 +92,6 @@ describe('TBankController', () => {
|
|||||||
expect(response.cachedAt).toBeNull();
|
expect(response.cachedAt).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns portfolio history with correct shape and points', async () => {
|
|
||||||
const historyData = {
|
|
||||||
accountId: 'acc-1',
|
|
||||||
points: [
|
|
||||||
{ month: '2026-01', label: 'Янв', value: { currency: 'RUB', units: '100000', nano: 0, value: 100000 } },
|
|
||||||
{ month: '2026-02', label: 'Фев', value: { currency: 'RUB', units: '100000', nano: 0, value: 100000 } },
|
|
||||||
],
|
|
||||||
asOf: '2026-06-22T00:00:00.000Z',
|
|
||||||
};
|
|
||||||
vi.mocked(portfolioHistory.getHistory).mockResolvedValueOnce(
|
|
||||||
new ApiEnvelopePayload(historyData, false, '2026-06-22T00:00:00.000Z'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
|
|
||||||
const response = await controller.getPortfolioHistory('acc-1', 6);
|
|
||||||
|
|
||||||
expect(portfolioHistory.getHistory).toHaveBeenCalledWith('acc-1', 6);
|
|
||||||
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
|
||||||
expect(response.data).toEqual(historyData);
|
|
||||||
expect(response.data.points).toHaveLength(2);
|
|
||||||
expect(response.data.points[0].month).toBe('2026-01');
|
|
||||||
expect(response.data.points[0].label).toBe('Янв');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes categories filter query to operations service', async () => {
|
|
||||||
const pageData = {
|
|
||||||
accountId: 'acc-1',
|
|
||||||
items: [],
|
|
||||||
nextCursor: null,
|
|
||||||
hasNext: false,
|
|
||||||
asOf: '2026-06-22T00:00:00.000Z',
|
|
||||||
};
|
|
||||||
vi.mocked(operations.getOperations).mockResolvedValueOnce(
|
|
||||||
new ApiEnvelopePayload(pageData, false, null),
|
|
||||||
);
|
|
||||||
|
|
||||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
|
|
||||||
const query = { from: '2026-06-01', to: '2026-06-30', categories: 'fee,tax' };
|
|
||||||
const response = await controller.getOperations('acc-1', query);
|
|
||||||
|
|
||||||
expect(operations.getOperations).toHaveBeenCalledWith('acc-1', query);
|
|
||||||
expect(response.data.accountId).toBe('acc-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses default months parameter when not provided', async () => {
|
|
||||||
const historyData = {
|
|
||||||
accountId: 'acc-1',
|
|
||||||
points: [],
|
|
||||||
asOf: '2026-06-22T00:00:00.000Z',
|
|
||||||
};
|
|
||||||
vi.mocked(portfolioHistory.getHistory).mockResolvedValueOnce(
|
|
||||||
new ApiEnvelopePayload(historyData, false, null),
|
|
||||||
);
|
|
||||||
|
|
||||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
|
|
||||||
const response = await controller.getPortfolioHistory('acc-1');
|
|
||||||
|
|
||||||
expect(portfolioHistory.getHistory).toHaveBeenCalledWith('acc-1', 6);
|
|
||||||
expect(response.data.points).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forwards events query and wraps response', async () => {
|
it('forwards events query and wraps response', async () => {
|
||||||
const eventsData = {
|
const eventsData = {
|
||||||
items: [],
|
items: [],
|
||||||
@ -179,7 +114,7 @@ describe('TBankController', () => {
|
|||||||
new ApiEnvelopePayload(eventsData, false, '2026-06-22T00:00:00.000Z'),
|
new ApiEnvelopePayload(eventsData, false, '2026-06-22T00:00:00.000Z'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
|
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||||
const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' };
|
const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' };
|
||||||
const response = await controller.getEvents('acc-1', query);
|
const response = await controller.getEvents('acc-1', query);
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import {
|
|||||||
BrokerOperationSyncEnvelopeDto,
|
BrokerOperationSyncEnvelopeDto,
|
||||||
BrokerOperationsEnvelopeDto,
|
BrokerOperationsEnvelopeDto,
|
||||||
BrokerPortfolioEnvelopeDto,
|
BrokerPortfolioEnvelopeDto,
|
||||||
BrokerPortfolioHistoryEnvelopeDto,
|
|
||||||
BrokerPositionsEnvelopeDto,
|
BrokerPositionsEnvelopeDto,
|
||||||
} from './dto/broker-envelope.dto';
|
} from './dto/broker-envelope.dto';
|
||||||
import { BrokerEventsQueryDto } from './dto/broker-events-query.dto';
|
import { BrokerEventsQueryDto } from './dto/broker-events-query.dto';
|
||||||
@ -16,11 +15,10 @@ import { BrokerPositionQueryDto } from './dto/broker-position-query.dto';
|
|||||||
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
|
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
|
||||||
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
|
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
|
||||||
import { BrokerAccountsService } from './services/broker-accounts.service';
|
import { BrokerAccountsService } from './services/broker-accounts.service';
|
||||||
import { BrokerAnalyticsService } from './services/broker-analytics.service';
|
|
||||||
import { BrokerEventsService } from './services/broker-events.service';
|
import { BrokerEventsService } from './services/broker-events.service';
|
||||||
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
||||||
import { BrokerOperationsService } from './services/broker-operations.service';
|
import { BrokerOperationsService } from './services/broker-operations.service';
|
||||||
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
|
import { BrokerAnalyticsService } from './services/broker-analytics.service';
|
||||||
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||||
|
|
||||||
@ApiTags('Broker')
|
@ApiTags('Broker')
|
||||||
@ -35,7 +33,6 @@ export class TBankController {
|
|||||||
private readonly brokerOperationsService: BrokerOperationsService,
|
private readonly brokerOperationsService: BrokerOperationsService,
|
||||||
private readonly brokerOperationSyncService: BrokerOperationSyncService,
|
private readonly brokerOperationSyncService: BrokerOperationSyncService,
|
||||||
private readonly brokerAnalyticsService: BrokerAnalyticsService,
|
private readonly brokerAnalyticsService: BrokerAnalyticsService,
|
||||||
private readonly brokerPortfolioHistoryService: BrokerPortfolioHistoryService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('accounts')
|
@Get('accounts')
|
||||||
@ -84,16 +81,6 @@ export class TBankController {
|
|||||||
return this.brokerEventsService.getEvents(accountId, query);
|
return this.brokerEventsService.getEvents(accountId, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('accounts/:accountId/portfolio/history')
|
|
||||||
@ApiOperation({ summary: 'Get portfolio value history for last N months' })
|
|
||||||
@ApiOkResponse({ type: BrokerPortfolioHistoryEnvelopeDto })
|
|
||||||
async getPortfolioHistory(
|
|
||||||
@Param('accountId') accountId: string,
|
|
||||||
@Query('months') months?: number,
|
|
||||||
) {
|
|
||||||
return this.brokerPortfolioHistoryService.getHistory(accountId, months ?? 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('accounts/:accountId/analytics')
|
@Get('accounts/:accountId/analytics')
|
||||||
@ApiOperation({ summary: 'Get broker account profitability analytics' })
|
@ApiOperation({ summary: 'Get broker account profitability analytics' })
|
||||||
@ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto })
|
@ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto })
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import { BrokerInstrumentsService } from './services/broker-instruments.service'
|
|||||||
import { BrokerEventsService } from './services/broker-events.service';
|
import { BrokerEventsService } from './services/broker-events.service';
|
||||||
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
|
||||||
import { BrokerOperationsService } from './services/broker-operations.service';
|
import { BrokerOperationsService } from './services/broker-operations.service';
|
||||||
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
|
|
||||||
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||||
import { TBankClientService } from './services/tbank-client.service';
|
import { TBankClientService } from './services/tbank-client.service';
|
||||||
|
|
||||||
@ -23,7 +22,6 @@ import { TBankClientService } from './services/tbank-client.service';
|
|||||||
BrokerOperationsService,
|
BrokerOperationsService,
|
||||||
BrokerOperationSyncService,
|
BrokerOperationSyncService,
|
||||||
BrokerAnalyticsService,
|
BrokerAnalyticsService,
|
||||||
BrokerPortfolioHistoryService,
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TBankClientService,
|
TBankClientService,
|
||||||
|
|||||||
@ -92,17 +92,9 @@
|
|||||||
**Response:**
|
**Response:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"data": {
|
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"timestamp": "2026-06-25T12:00:00.000Z",
|
"timestamp": "2026-06-13T12:00:00.000Z",
|
||||||
"uptime": 1234.56,
|
"uptime": 1234.56
|
||||||
"checks": [
|
|
||||||
{ "name": "database", "status": "ok" },
|
|
||||||
{ "name": "moex", "status": "ok" },
|
|
||||||
{ "name": "tbank", "status": "ok" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"meta": { "fromCache": false, "cachedAt": null }
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -411,5 +403,5 @@ codegen types.
|
|||||||
| `/api/v1/broker/accounts/:accountId/portfolio` | GET | Портфель счёта: позиции, cash, метаданные |
|
| `/api/v1/broker/accounts/:accountId/portfolio` | GET | Портфель счёта: позиции, cash, метаданные |
|
||||||
| `/api/v1/broker/accounts/:accountId/events` | GET | События и будущие выплаты (дивиденды, купоны) с фильтром по датам |
|
| `/api/v1/broker/accounts/:accountId/events` | GET | События и будущие выплаты (дивиденды, купоны) с фильтром по датам |
|
||||||
| `/api/v1/broker/accounts/:accountId/operations` | GET | История операций (cursor pagination) |
|
| `/api/v1/broker/accounts/:accountId/operations` | GET | История операций (cursor pagination) |
|
||||||
| `/api/v1/broker/accounts/:accountId/operations/sync` | POST | Принудительная синхронизация операций из T-Bank |
|
| `/api/v1/broker/accounts/:accountId/operations/refresh` | POST | Принудительная синхронизация операций из T-Bank |
|
||||||
| `/api/v1/broker/accounts/:accountId/positions` | GET | Позиции счёта (с пагинацией) |
|
| `/api/v1/broker/accounts/:accountId/positions` | GET | Позиции счёта (с пагинацией) |
|
||||||
|
|||||||
@ -25,20 +25,20 @@ flowchart TB
|
|||||||
|
|
||||||
PrismaService["PrismaService"]
|
PrismaService["PrismaService"]
|
||||||
CacheService["CacheService"]
|
CacheService["CacheService"]
|
||||||
MoexHttpClient["MoexHttpClient"]
|
MoexClientService["MoexClientService"]
|
||||||
TBankClientService["TBankClientService"]
|
TBankClientService["TBankClientService"]
|
||||||
|
|
||||||
PrismaModule --> PrismaService
|
PrismaModule --> PrismaService
|
||||||
CacheModule --> CacheService
|
CacheModule --> CacheService
|
||||||
MoexClientModule --> MoexHttpClient
|
MoexClientModule --> MoexClientService
|
||||||
|
|
||||||
AuthModule --> PrismaService
|
AuthModule --> PrismaService
|
||||||
PortfolioModule --> PrismaService
|
PortfolioModule --> PrismaService
|
||||||
PortfolioModule --> CacheService
|
PortfolioModule --> CacheService
|
||||||
PortfolioModule --> MoexHttpClient
|
PortfolioModule --> MoexClientService
|
||||||
|
|
||||||
MarketModules --> CacheService
|
MarketModules --> CacheService
|
||||||
MarketModules --> MoexHttpClient
|
MarketModules --> MoexClientService
|
||||||
|
|
||||||
TBankModule --> CacheService
|
TBankModule --> CacheService
|
||||||
TBankModule --> PrismaService
|
TBankModule --> PrismaService
|
||||||
@ -51,7 +51,7 @@ flowchart TB
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `PrismaModule` | Да | `modules/prisma/` | Prisma client для SQLite |
|
| `PrismaModule` | Да | `modules/prisma/` | Prisma client для SQLite |
|
||||||
| `CacheModule` | Да | `modules/cache/` | In-memory cache через cache-manager |
|
| `CacheModule` | Да | `modules/cache/` | In-memory cache через cache-manager |
|
||||||
| `MoexClientModule` | Нет | `modules/moex-client/` | HTTP- и domain-клиенты MOEX ISS |
|
| `MoexClientModule` | Да | `modules/moex-client/` | HTTP-клиент MOEX ISS |
|
||||||
| `HealthModule` | Нет | `modules/health/` | Health check endpoint |
|
| `HealthModule` | Нет | `modules/health/` | Health check endpoint |
|
||||||
| `AuthModule` | Нет | `modules/auth/` | JWT auth, refresh cookie, guards |
|
| `AuthModule` | Нет | `modules/auth/` | JWT auth, refresh cookie, guards |
|
||||||
| `SecuritiesModule` | Нет | `modules/securities/` | Поиск инструментов |
|
| `SecuritiesModule` | Нет | `modules/securities/` | Поиск инструментов |
|
||||||
@ -79,17 +79,17 @@ flowchart TB
|
|||||||
|
|
||||||
### MoexClientModule
|
### MoexClientModule
|
||||||
|
|
||||||
Модуль для MOEX ISS, разделённый на `MoexHttpClient` и domain-specific клиенты.
|
Глобальный HTTP-клиент для MOEX ISS.
|
||||||
|
|
||||||
- `MoexHttpClient` — rate limiter (p-queue, 10 req/s), circuit breaker (5 errors → 30s open), ISS JSON parsing.
|
- Rate limiter: p-queue (10 req/s по умолчанию, настраивается через `MOEX_RATE_LIMIT`)
|
||||||
- Domain clients — `MoexSecuritiesClient`, `MoexMarketDataClient`, `MoexCandlesClient`, `MoexHistoryClient`, `MoexDividendsClient`.
|
- Circuit breaker: открывается после 5 ошибок, сбрасывается через 30s
|
||||||
- Все ответы нормализуются из табличного формата MOEX в доменные типы.
|
- Все ответы нормализуются из табличного формата MOEX в доменные типы
|
||||||
|
|
||||||
### HealthModule
|
### HealthModule
|
||||||
|
|
||||||
Проверка состояния сервиса.
|
Проверка состояния сервиса.
|
||||||
|
|
||||||
- `GET /api/v1/health` → `{ data: { status, timestamp, uptime, checks }, meta }`
|
- `GET /api/v1/health` → `{ status: 'ok', timestamp, uptime }`
|
||||||
|
|
||||||
### AuthModule
|
### AuthModule
|
||||||
|
|
||||||
|
|||||||
@ -2,21 +2,9 @@
|
|||||||
|
|
||||||
## Обзор
|
## Обзор
|
||||||
|
|
||||||
MOEX integration is split into a shared HTTP infrastructure client and focused domain clients under
|
`MoexClientService` (`apps/backend/src/modules/moex-client/moex-client.service.ts`) — HTTP-клиент для MOEX ISS API.
|
||||||
`apps/backend/src/modules/moex-client/`:
|
|
||||||
|
|
||||||
- `MoexHttpClient` — request queue, rate limiting, circuit breaker, ISS JSON parsing.
|
## Rate limiting
|
||||||
- `MoexSecuritiesClient` — security search and descriptions.
|
|
||||||
- `MoexMarketDataClient` — share/bond market data and batch position enrichment.
|
|
||||||
- `MoexCandlesClient` — candle history.
|
|
||||||
- `MoexHistoryClient` — share and bond history.
|
|
||||||
- `MoexDividendsClient` — dividend calendar.
|
|
||||||
|
|
||||||
## `MoexHttpClient`
|
|
||||||
|
|
||||||
Базовый HTTP-клиент, используемый всеми domain-клиентами. Реализован в `moex-http-client.service.ts`.
|
|
||||||
|
|
||||||
### Rate limiting
|
|
||||||
|
|
||||||
Использует `p-queue`:
|
Использует `p-queue`:
|
||||||
|
|
||||||
@ -29,7 +17,7 @@ this.queue = new PQueue({
|
|||||||
|
|
||||||
Все запросы к MOEX проходят через очередь — не более `MOEX_RATE_LIMIT` запросов в секунду.
|
Все запросы к MOEX проходят через очередь — не более `MOEX_RATE_LIMIT` запросов в секунду.
|
||||||
|
|
||||||
### Circuit breaker
|
## Circuit breaker
|
||||||
|
|
||||||
Состояние: закрыт → открыт → полуоткрыт (через таймаут).
|
Состояние: закрыт → открыт → полуоткрыт (через таймаут).
|
||||||
|
|
||||||
@ -42,7 +30,7 @@ private circuitErrorCount = 0;
|
|||||||
- В открытом состоянии все запросы мгновенно падают с ошибкой `"Circuit breaker is open"`
|
- В открытом состоянии все запросы мгновенно падают с ошибкой `"Circuit breaker is open"`
|
||||||
- Через `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` (30) автоматически сбрасывается
|
- Через `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` (30) автоматически сбрасывается
|
||||||
|
|
||||||
### Метод request
|
## Метод request
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
private async request<T>(path: string, params?: Record<string, string>): Promise<T>
|
private async request<T>(path: string, params?: Record<string, string>): Promise<T>
|
||||||
@ -52,7 +40,7 @@ private async request<T>(path: string, params?: Record<string, string>): Promise
|
|||||||
- Устанавливает `iss.meta=off` (отключает метаданные)
|
- Устанавливает `iss.meta=off` (отключает метаданные)
|
||||||
- Таймаут: 10s
|
- Таймаут: 10s
|
||||||
|
|
||||||
### Разбор response
|
## Разбор response
|
||||||
|
|
||||||
MOEX возвращает данные в табличном формате:
|
MOEX возвращает данные в табличном формате:
|
||||||
|
|
||||||
@ -67,17 +55,19 @@ MOEX возвращает данные в табличном формате:
|
|||||||
|
|
||||||
Метод `extractTable` преобразует это в массив объектов по колонкам.
|
Метод `extractTable` преобразует это в массив объектов по колонкам.
|
||||||
|
|
||||||
## Domain clients
|
## Доступные методы MOEX
|
||||||
|
|
||||||
Каждый domain-клиент использует `MoexHttpClient` для HTTP и предоставляет свои методы:
|
| Метод | MOEX path | Описание |
|
||||||
|
|---|---|---|
|
||||||
| Client | Методы |
|
| `searchSecurities` | `/securities?q=` | Поиск инструментов |
|
||||||
|---|---|
|
| `getSecurityDescription` | `/securities/{secid}` | Спецификация |
|
||||||
| `MoexSecuritiesClient` | `searchSecurities`, `getSecurityDescription` |
|
| `getShareMarketData` | `/engines/stock/markets/shares/securities/{secid}` | Рыночные данные акции (board: TQBR) |
|
||||||
| `MoexMarketDataClient` | `getShareMarketData`, `getShareMarketDataBatch`, `getBondData`, `getBondMarketData`, `getBondPositionDataBatch` |
|
| `getBondData` | `/engines/stock/markets/bonds/securities/{secid}` | Данные облигации (board: TQCB) |
|
||||||
| `MoexCandlesClient` | `getCandles` |
|
| `getBondMarketData` | `/engines/stock/markets/bonds/securities/{secid}` | Рыночные данные облигации |
|
||||||
| `MoexHistoryClient` | `getShareHistory`, `getBondHistory` |
|
| `getDividends` | `/securities/{secid}/dividends` | Дивиденды |
|
||||||
| `MoexDividendsClient` | `getDividends` |
|
| `getCandles` | `/engines/{engine}/markets/{market}/securities/{secid}/candles` | Свечи |
|
||||||
|
| `getHistory` | `/engines/stock/markets/shares/securities/{secid}` | История акций |
|
||||||
|
| `getBondHistory` | `/engines/stock/markets/bonds/securities/{secid}` | История облигаций |
|
||||||
|
|
||||||
## MOEX ISS types
|
## MOEX ISS types
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,6 @@
|
|||||||
"@moex-vibe/design-system": "*",
|
"@moex-vibe/design-system": "*",
|
||||||
"@mui/icons-material": "^6.5.0",
|
"@mui/icons-material": "^6.5.0",
|
||||||
"@mui/material": "^6.5.0",
|
"@mui/material": "^6.5.0",
|
||||||
"@mui/x-date-pickers": "^7.29.4",
|
|
||||||
"@tanstack/react-query": "^5.20.0",
|
"@tanstack/react-query": "^5.20.0",
|
||||||
"@tanstack/react-router": "^1.170.16",
|
"@tanstack/react-router": "^1.170.16",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
@ -44,6 +43,7 @@
|
|||||||
"@biomejs/biome": "^2.5.0",
|
"@biomejs/biome": "^2.5.0",
|
||||||
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
|
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
|
||||||
"@tanstack/router-devtools": "^1.167.0",
|
"@tanstack/router-devtools": "^1.167.0",
|
||||||
|
|
||||||
"@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",
|
||||||
|
|||||||
@ -18,13 +18,12 @@ export function AppLayout() {
|
|||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
borderBottom: '1px solid #e0e0e0',
|
borderBottom: '1px solid #e0e0e0',
|
||||||
padding: '12px 24px',
|
padding: '12px 24px',
|
||||||
display: 'grid',
|
display: 'flex',
|
||||||
gridTemplateColumns: 'minmax(0, 1fr) auto',
|
alignItems: 'center',
|
||||||
gap: 12,
|
flexWrap: 'wrap',
|
||||||
|
gap: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'grid', gap: 12 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
|
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
style={{
|
style={{
|
||||||
@ -39,11 +38,6 @@ export function AppLayout() {
|
|||||||
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
|
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<nav
|
|
||||||
style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}
|
|
||||||
aria-label="Основная навигация"
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
to="/portfolios"
|
to="/portfolios"
|
||||||
style={{
|
style={{
|
||||||
@ -77,13 +71,12 @@ export function AppLayout() {
|
|||||||
>
|
>
|
||||||
Скринер
|
Скринер
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'flex-end',
|
|
||||||
gap: 12,
|
gap: 12,
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
import type { ApiResponseMeta, BrokerPortfolioHistoryData } from '@/shared/api'
|
|
||||||
import { request } from '@/shared/api/kyClient'
|
|
||||||
|
|
||||||
export function getBrokerPortfolioHistory(
|
|
||||||
accountId: string,
|
|
||||||
months?: number,
|
|
||||||
): Promise<{ data: BrokerPortfolioHistoryData; meta: ApiResponseMeta }> {
|
|
||||||
return request<BrokerPortfolioHistoryData>(
|
|
||||||
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio/history`,
|
|
||||||
{ months: months ? String(months) : undefined },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -10,4 +10,3 @@ export {
|
|||||||
export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios'
|
export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios'
|
||||||
export { useBrokerAccounts } from './model/useBrokerAccounts'
|
export { useBrokerAccounts } from './model/useBrokerAccounts'
|
||||||
export { useBrokerPortfolio } from './model/useBrokerPortfolio'
|
export { useBrokerPortfolio } from './model/useBrokerPortfolio'
|
||||||
export { useBrokerPortfolioHistory } from './model/useBrokerPortfolioHistory'
|
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import type { BrokerPortfolioHistoryData } from '@/shared/api'
|
|
||||||
import { getBrokerPortfolioHistory } from '../api/brokerPortfolioHistoryApi'
|
|
||||||
|
|
||||||
export function useBrokerPortfolioHistory(accountId: string, months: number = 6) {
|
|
||||||
return useQuery<BrokerPortfolioHistoryData>({
|
|
||||||
queryKey: ['broker', 'portfolio-history', accountId, months],
|
|
||||||
enabled: Boolean(accountId),
|
|
||||||
queryFn: async () => (await getBrokerPortfolioHistory(accountId, months)).data,
|
|
||||||
staleTime: 300_000,
|
|
||||||
retry: 2,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -2,15 +2,11 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import type { BrokerEventsData } from '@/shared/api'
|
import type { BrokerEventsData } from '@/shared/api'
|
||||||
import { type BrokerEventsQuery, getBrokerEvents } from '../api/brokerEventApi'
|
import { type BrokerEventsQuery, getBrokerEvents } from '../api/brokerEventApi'
|
||||||
|
|
||||||
export function useBrokerEvents(
|
export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) {
|
||||||
accountId: string | undefined,
|
|
||||||
query: BrokerEventsQuery,
|
|
||||||
options: { enabled?: boolean } = {},
|
|
||||||
) {
|
|
||||||
const { from, to, types } = query
|
const { from, to, types } = query
|
||||||
return useQuery<BrokerEventsData>({
|
return useQuery<BrokerEventsData>({
|
||||||
queryKey: ['broker', 'events', accountId, from, to, types],
|
queryKey: ['broker', 'events', accountId, from, to, types],
|
||||||
enabled: Boolean(accountId) && (options.enabled ?? true),
|
enabled: Boolean(accountId),
|
||||||
queryFn: async () => (await getBrokerEvents(accountId!, { from, to, types })).data,
|
queryFn: async () => (await getBrokerEvents(accountId!, { from, to, types })).data,
|
||||||
staleTime: 300_000,
|
staleTime: 300_000,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
|
|||||||
@ -13,7 +13,6 @@ export type BrokerOperationQuery = {
|
|||||||
instrumentId?: string
|
instrumentId?: string
|
||||||
operationTypes?: string
|
operationTypes?: string
|
||||||
state?: string
|
state?: string
|
||||||
categories?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncBrokerOperations(
|
export function syncBrokerOperations(
|
||||||
@ -41,7 +40,6 @@ export function getBrokerOperations(
|
|||||||
instrumentId: query.instrumentId,
|
instrumentId: query.instrumentId,
|
||||||
operationTypes: query.operationTypes,
|
operationTypes: query.operationTypes,
|
||||||
state: query.state,
|
state: query.state,
|
||||||
categories: query.categories,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,11 +5,10 @@ import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOpe
|
|||||||
export function useBrokerOperations(
|
export function useBrokerOperations(
|
||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
query: BrokerOperationQuery = {},
|
query: BrokerOperationQuery = {},
|
||||||
options: { enabled?: boolean } = {},
|
|
||||||
) {
|
) {
|
||||||
return useQuery<BrokerOperationsPage>({
|
return useQuery<BrokerOperationsPage>({
|
||||||
queryKey: ['broker', 'operations', accountId, query],
|
queryKey: ['broker', 'operations', accountId, query],
|
||||||
enabled: Boolean(accountId) && (options.enabled ?? true),
|
enabled: Boolean(accountId),
|
||||||
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
|
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
|
||||||
staleTime: 300_000,
|
staleTime: 300_000,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
|
|||||||
@ -1,11 +1,18 @@
|
|||||||
import { Text } from '@moex-vibe/design-system'
|
import { Text } from '@moex-vibe/design-system'
|
||||||
|
import { Box } from '@mui/material'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { useBrokerOperations } from '@/entities/broker-operation'
|
||||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'
|
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'
|
||||||
import { BrokerDashboard, BrokerDashboardSkeleton } from '@/widgets/broker-dashboard'
|
import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'
|
||||||
|
import { BrokerEventsOverview } from '@/widgets/broker-events-overview'
|
||||||
|
import { BrokerOperationsTable } from '@/widgets/broker-operations-table'
|
||||||
|
import { BrokerAssetCards, BrokerOverviewSkeleton, BrokerSummary } from '@/widgets/broker-overview'
|
||||||
|
|
||||||
export function BrokerAccountOverviewPage() {
|
export function BrokerAccountOverviewPage() {
|
||||||
const { accountId, portfolio } = useBrokerAccountContext()
|
const { accountId, portfolio } = useBrokerAccountContext()
|
||||||
|
const operations = useBrokerOperations(accountId, { limit: 5 })
|
||||||
|
|
||||||
if (portfolio.isLoading) return <BrokerDashboardSkeleton />
|
if (portfolio.isLoading) return <BrokerOverviewSkeleton />
|
||||||
if (portfolio.error || !portfolio.data) {
|
if (portfolio.error || !portfolio.data) {
|
||||||
return (
|
return (
|
||||||
<Text component="p" role="alert" tone="negative">
|
<Text component="p" role="alert" tone="negative">
|
||||||
@ -14,5 +21,28 @@ export function BrokerAccountOverviewPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <BrokerDashboard accountId={accountId} portfolio={portfolio.data} />
|
return (
|
||||||
|
<Box component="div" sx={{ display: 'grid', gap: 3 }}>
|
||||||
|
<BrokerSummary portfolio={portfolio.data} />
|
||||||
|
<BrokerAllocationChart portfolio={portfolio.data} />
|
||||||
|
<BrokerAssetCards accountId={accountId} portfolio={portfolio.data} />
|
||||||
|
<BrokerEventsOverview accountId={accountId} />
|
||||||
|
{operations.error ? (
|
||||||
|
<Text component="p" role="alert" tone="negative">
|
||||||
|
Не удалось загрузить последние операции
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<BrokerOperationsTable
|
||||||
|
title="Последние операции"
|
||||||
|
headerAction={
|
||||||
|
<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Вся история</Link>
|
||||||
|
}
|
||||||
|
emptyMessage="Операций с начала текущего года нет"
|
||||||
|
isLoading={operations.isLoading}
|
||||||
|
isFetching={operations.isFetching}
|
||||||
|
page={operations.data}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,9 +64,5 @@ export type BrokerEventsSummary = components['schemas']['BrokerEventsSummaryDto'
|
|||||||
// Broker analytics
|
// Broker analytics
|
||||||
export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto']
|
export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto']
|
||||||
|
|
||||||
// Broker portfolio history
|
|
||||||
export type BrokerPortfolioHistoryPoint = components['schemas']['BrokerPortfolioHistoryPointDto']
|
|
||||||
export type BrokerPortfolioHistoryData = components['schemas']['BrokerPortfolioHistoryDataDto']
|
|
||||||
|
|
||||||
// Broker sync
|
// Broker sync
|
||||||
export type BrokerOperationSyncResponse = components['schemas']['BrokerOperationSyncResponseDto']
|
export type BrokerOperationSyncResponse = components['schemas']['BrokerOperationSyncResponseDto']
|
||||||
|
|||||||
@ -468,23 +468,6 @@ export interface paths {
|
|||||||
patch?: never
|
patch?: never
|
||||||
trace?: never
|
trace?: never
|
||||||
}
|
}
|
||||||
'/api/v1/broker/accounts/{accountId}/portfolio/history': {
|
|
||||||
parameters: {
|
|
||||||
query?: never
|
|
||||||
header?: never
|
|
||||||
path?: never
|
|
||||||
cookie?: never
|
|
||||||
}
|
|
||||||
/** Get portfolio value history for last N months */
|
|
||||||
get: operations['TBankController_getPortfolioHistory']
|
|
||||||
put?: never
|
|
||||||
post?: never
|
|
||||||
delete?: never
|
|
||||||
options?: never
|
|
||||||
head?: never
|
|
||||||
patch?: never
|
|
||||||
trace?: never
|
|
||||||
}
|
|
||||||
'/api/v1/broker/accounts/{accountId}/analytics': {
|
'/api/v1/broker/accounts/{accountId}/analytics': {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never
|
query?: never
|
||||||
@ -527,13 +510,6 @@ export interface components {
|
|||||||
cachedAt: string | null
|
cachedAt: string | null
|
||||||
fromCache: boolean
|
fromCache: boolean
|
||||||
}
|
}
|
||||||
HealthCheckResultDto: {
|
|
||||||
/** @example prisma */
|
|
||||||
name: string
|
|
||||||
/** @enum {string} */
|
|
||||||
status: 'ok' | 'error'
|
|
||||||
error?: string | null
|
|
||||||
}
|
|
||||||
HealthResponseDto: {
|
HealthResponseDto: {
|
||||||
/** @example ok */
|
/** @example ok */
|
||||||
status: string
|
status: string
|
||||||
@ -541,7 +517,6 @@ export interface components {
|
|||||||
timestamp: string
|
timestamp: string
|
||||||
/** @example 12345 */
|
/** @example 12345 */
|
||||||
uptime: number
|
uptime: number
|
||||||
checks: components['schemas']['HealthCheckResultDto'][]
|
|
||||||
}
|
}
|
||||||
HealthEnvelopeDto: {
|
HealthEnvelopeDto: {
|
||||||
data: components['schemas']['HealthResponseDto']
|
data: components['schemas']['HealthResponseDto']
|
||||||
@ -565,9 +540,13 @@ export interface components {
|
|||||||
user: components['schemas']['AuthUserDto']
|
user: components['schemas']['AuthUserDto']
|
||||||
accessToken: string
|
accessToken: string
|
||||||
}
|
}
|
||||||
|
AuthResponseMetaDto: {
|
||||||
|
cachedAt: string | null
|
||||||
|
fromCache: boolean
|
||||||
|
}
|
||||||
AuthTokenResponseDto: {
|
AuthTokenResponseDto: {
|
||||||
data: components['schemas']['AuthTokenDataDto']
|
data: components['schemas']['AuthTokenDataDto']
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['AuthResponseMetaDto']
|
||||||
}
|
}
|
||||||
LoginDto: {
|
LoginDto: {
|
||||||
/** @example user@example.com */
|
/** @example user@example.com */
|
||||||
@ -580,11 +559,11 @@ export interface components {
|
|||||||
}
|
}
|
||||||
AuthLogoutResponseDto: {
|
AuthLogoutResponseDto: {
|
||||||
data: components['schemas']['LogoutDataDto']
|
data: components['schemas']['LogoutDataDto']
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['AuthResponseMetaDto']
|
||||||
}
|
}
|
||||||
AuthProfileResponseDto: {
|
AuthProfileResponseDto: {
|
||||||
data: components['schemas']['AuthUserDto']
|
data: components['schemas']['AuthUserDto']
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['AuthResponseMetaDto']
|
||||||
}
|
}
|
||||||
UpdateProfileDto: {
|
UpdateProfileDto: {
|
||||||
/** @example John Doe */
|
/** @example John Doe */
|
||||||
@ -653,9 +632,13 @@ export interface components {
|
|||||||
pageSize: number
|
pageSize: number
|
||||||
totalPages: number
|
totalPages: number
|
||||||
}
|
}
|
||||||
|
ScreenerResponseMetaDto: {
|
||||||
|
cachedAt: string | null
|
||||||
|
fromCache: boolean
|
||||||
|
}
|
||||||
ScreenerResponseDto: {
|
ScreenerResponseDto: {
|
||||||
data: components['schemas']['ScreenerResultDto']
|
data: components['schemas']['ScreenerResultDto']
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['ScreenerResponseMetaDto']
|
||||||
}
|
}
|
||||||
StockMarketDataDto: {
|
StockMarketDataDto: {
|
||||||
/** @example 322.35 */
|
/** @example 322.35 */
|
||||||
@ -866,6 +849,10 @@ export interface components {
|
|||||||
data: components['schemas']['CandleItemDto'][]
|
data: components['schemas']['CandleItemDto'][]
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['ApiResponseMeta']
|
||||||
}
|
}
|
||||||
|
PortfolioResponseMetaDto: {
|
||||||
|
cachedAt: string | null
|
||||||
|
fromCache: boolean
|
||||||
|
}
|
||||||
PortfolioListResponseDto: {
|
PortfolioListResponseDto: {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@ -889,7 +876,7 @@ export interface components {
|
|||||||
}
|
}
|
||||||
PortfolioListEnvelopeDto: {
|
PortfolioListEnvelopeDto: {
|
||||||
data: components['schemas']['PortfolioListResponseDto'][]
|
data: components['schemas']['PortfolioListResponseDto'][]
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['PortfolioResponseMetaDto']
|
||||||
}
|
}
|
||||||
CreatePortfolioDto: {
|
CreatePortfolioDto: {
|
||||||
/** @example Мой портфель */
|
/** @example Мой портфель */
|
||||||
@ -917,7 +904,7 @@ export interface components {
|
|||||||
}
|
}
|
||||||
PortfolioEnvelopeDto: {
|
PortfolioEnvelopeDto: {
|
||||||
data: components['schemas']['PortfolioResponseDto']
|
data: components['schemas']['PortfolioResponseDto']
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['PortfolioResponseMetaDto']
|
||||||
}
|
}
|
||||||
PositionWithPriceDto: {
|
PositionWithPriceDto: {
|
||||||
id: number
|
id: number
|
||||||
@ -994,7 +981,7 @@ export interface components {
|
|||||||
}
|
}
|
||||||
PortfolioDetailEnvelopeDto: {
|
PortfolioDetailEnvelopeDto: {
|
||||||
data: components['schemas']['PortfolioDetailResponseDto']
|
data: components['schemas']['PortfolioDetailResponseDto']
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['PortfolioResponseMetaDto']
|
||||||
}
|
}
|
||||||
PortfolioTargetsDto: {
|
PortfolioTargetsDto: {
|
||||||
/** @example 70 */
|
/** @example 70 */
|
||||||
@ -1062,7 +1049,7 @@ export interface components {
|
|||||||
}
|
}
|
||||||
PositionEnvelopeDto: {
|
PositionEnvelopeDto: {
|
||||||
data: components['schemas']['PositionResponseDto']
|
data: components['schemas']['PositionResponseDto']
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['PortfolioResponseMetaDto']
|
||||||
}
|
}
|
||||||
UpdatePositionDto: {
|
UpdatePositionDto: {
|
||||||
/** @example 15 */
|
/** @example 15 */
|
||||||
@ -1095,7 +1082,7 @@ export interface components {
|
|||||||
}
|
}
|
||||||
AnalyticsEnvelopeDto: {
|
AnalyticsEnvelopeDto: {
|
||||||
data: components['schemas']['AnalyticsResponseDto']
|
data: components['schemas']['AnalyticsResponseDto']
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['PortfolioResponseMetaDto']
|
||||||
}
|
}
|
||||||
BrokerAccountResponseDto: {
|
BrokerAccountResponseDto: {
|
||||||
id: string
|
id: string
|
||||||
@ -1106,9 +1093,13 @@ export interface components {
|
|||||||
openedAt: Record<string, never> | null
|
openedAt: Record<string, never> | null
|
||||||
accessLevel: Record<string, never> | null
|
accessLevel: Record<string, never> | null
|
||||||
}
|
}
|
||||||
|
BrokerResponseMetaDto: {
|
||||||
|
cachedAt: Record<string, never> | null
|
||||||
|
fromCache: boolean
|
||||||
|
}
|
||||||
BrokerAccountsEnvelopeDto: {
|
BrokerAccountsEnvelopeDto: {
|
||||||
data: components['schemas']['BrokerAccountResponseDto'][]
|
data: components['schemas']['BrokerAccountResponseDto'][]
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['BrokerResponseMetaDto']
|
||||||
}
|
}
|
||||||
BrokerPortfolioPositionCountsDto: {
|
BrokerPortfolioPositionCountsDto: {
|
||||||
shares: number
|
shares: number
|
||||||
@ -1149,7 +1140,7 @@ export interface components {
|
|||||||
}
|
}
|
||||||
BrokerPortfolioEnvelopeDto: {
|
BrokerPortfolioEnvelopeDto: {
|
||||||
data: components['schemas']['BrokerPortfolioResponseDto']
|
data: components['schemas']['BrokerPortfolioResponseDto']
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['BrokerResponseMetaDto']
|
||||||
}
|
}
|
||||||
BrokerPositionResponseDto: {
|
BrokerPositionResponseDto: {
|
||||||
figi: Record<string, never> | null
|
figi: Record<string, never> | null
|
||||||
@ -1176,7 +1167,7 @@ export interface components {
|
|||||||
}
|
}
|
||||||
BrokerPositionsEnvelopeDto: {
|
BrokerPositionsEnvelopeDto: {
|
||||||
data: components['schemas']['BrokerPositionsPageResponseDto']
|
data: components['schemas']['BrokerPositionsPageResponseDto']
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['BrokerResponseMetaDto']
|
||||||
}
|
}
|
||||||
BrokerOperationResponseDto: {
|
BrokerOperationResponseDto: {
|
||||||
cursor: Record<string, never> | null
|
cursor: Record<string, never> | null
|
||||||
@ -1212,7 +1203,7 @@ export interface components {
|
|||||||
}
|
}
|
||||||
BrokerOperationsEnvelopeDto: {
|
BrokerOperationsEnvelopeDto: {
|
||||||
data: components['schemas']['BrokerOperationsPageResponseDto']
|
data: components['schemas']['BrokerOperationsPageResponseDto']
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['BrokerResponseMetaDto']
|
||||||
}
|
}
|
||||||
BrokerEventItemDto: {
|
BrokerEventItemDto: {
|
||||||
id: string
|
id: string
|
||||||
@ -1257,21 +1248,7 @@ export interface components {
|
|||||||
}
|
}
|
||||||
BrokerEventsEnvelopeDto: {
|
BrokerEventsEnvelopeDto: {
|
||||||
data: components['schemas']['BrokerEventsDataDto']
|
data: components['schemas']['BrokerEventsDataDto']
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['BrokerResponseMetaDto']
|
||||||
}
|
|
||||||
BrokerPortfolioHistoryPointDto: {
|
|
||||||
month: string
|
|
||||||
label: string
|
|
||||||
value: components['schemas']['BrokerMoneyDto']
|
|
||||||
}
|
|
||||||
BrokerPortfolioHistoryDataDto: {
|
|
||||||
accountId: string
|
|
||||||
points: components['schemas']['BrokerPortfolioHistoryPointDto'][]
|
|
||||||
asOf: string
|
|
||||||
}
|
|
||||||
BrokerPortfolioHistoryEnvelopeDto: {
|
|
||||||
data: components['schemas']['BrokerPortfolioHistoryDataDto']
|
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
|
||||||
}
|
}
|
||||||
BrokerAnalyticsDto: {
|
BrokerAnalyticsDto: {
|
||||||
totalDeposits: number
|
totalDeposits: number
|
||||||
@ -1280,14 +1257,12 @@ export interface components {
|
|||||||
totalDividends: number
|
totalDividends: number
|
||||||
totalCoupons: number
|
totalCoupons: number
|
||||||
totalReceived: number
|
totalReceived: number
|
||||||
totalFees: number
|
|
||||||
totalTaxesPaid: number
|
|
||||||
totalReturnPercent: number | null
|
totalReturnPercent: number | null
|
||||||
currency: string
|
currency: string
|
||||||
}
|
}
|
||||||
BrokerAnalyticsEnvelopeDto: {
|
BrokerAnalyticsEnvelopeDto: {
|
||||||
data: components['schemas']['BrokerAnalyticsDto']
|
data: components['schemas']['BrokerAnalyticsDto']
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['BrokerResponseMetaDto']
|
||||||
}
|
}
|
||||||
BrokerOperationSyncResponseDto: {
|
BrokerOperationSyncResponseDto: {
|
||||||
/** @example 42 */
|
/** @example 42 */
|
||||||
@ -1295,7 +1270,7 @@ export interface components {
|
|||||||
}
|
}
|
||||||
BrokerOperationSyncEnvelopeDto: {
|
BrokerOperationSyncEnvelopeDto: {
|
||||||
data: components['schemas']['BrokerOperationSyncResponseDto']
|
data: components['schemas']['BrokerOperationSyncResponseDto']
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['BrokerResponseMetaDto']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
responses: never
|
responses: never
|
||||||
@ -1802,7 +1777,7 @@ export interface operations {
|
|||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
data: null
|
data: null
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['PortfolioResponseMetaDto']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1877,7 +1852,7 @@ export interface operations {
|
|||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
data: null
|
data: null
|
||||||
meta: components['schemas']['ApiResponseMeta']
|
meta: components['schemas']['PortfolioResponseMetaDto']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2007,8 +1982,6 @@ export interface operations {
|
|||||||
instrumentId?: string
|
instrumentId?: string
|
||||||
operationTypes?: string
|
operationTypes?: string
|
||||||
state?: string
|
state?: string
|
||||||
/** @description Comma-separated category filter: trade,income,tax,fee,transfer,other */
|
|
||||||
categories?: string
|
|
||||||
}
|
}
|
||||||
header?: never
|
header?: never
|
||||||
path: {
|
path: {
|
||||||
@ -2056,29 +2029,6 @@ export interface operations {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TBankController_getPortfolioHistory: {
|
|
||||||
parameters: {
|
|
||||||
query: {
|
|
||||||
months: number
|
|
||||||
}
|
|
||||||
header?: never
|
|
||||||
path: {
|
|
||||||
accountId: string
|
|
||||||
}
|
|
||||||
cookie?: never
|
|
||||||
}
|
|
||||||
requestBody?: never
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown
|
|
||||||
}
|
|
||||||
content: {
|
|
||||||
'application/json': components['schemas']['BrokerPortfolioHistoryEnvelopeDto']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TBankController_getAnalytics: {
|
TBankController_getAnalytics: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never
|
query?: never
|
||||||
|
|||||||
@ -1,16 +1,14 @@
|
|||||||
import { Heading, Skeleton } from '@moex-vibe/design-system'
|
import { Heading } from '@moex-vibe/design-system'
|
||||||
import { Box } from '@mui/material'
|
import { Box } from '@mui/material'
|
||||||
import type { UseQueryResult } from '@tanstack/react-query'
|
import type { UseQueryResult } from '@tanstack/react-query'
|
||||||
import { Link, useParams } from '@tanstack/react-router'
|
import { Link, useParams } from '@tanstack/react-router'
|
||||||
import { createContext, type ReactNode } from 'react'
|
import { createContext, type ReactNode } from 'react'
|
||||||
import { useBrokerPortfolio } from '@/entities/broker-account'
|
import { useBrokerPortfolio } from '@/entities/broker-account'
|
||||||
import type { BrokerPortfolio } from '@/shared/api'
|
import type { BrokerPortfolio } from '@/shared/api'
|
||||||
import { formatBrokerMoney, formatBrokerPercent } from '@/shared/lib/formatters'
|
|
||||||
import { moneyTone } from '@/widgets/broker-dashboard/lib/dashboardVisual'
|
|
||||||
|
|
||||||
const baseLinkStyle: React.CSSProperties = {
|
const baseLinkStyle: React.CSSProperties = {
|
||||||
padding: '10px 14px',
|
padding: '10px 12px',
|
||||||
borderRadius: 999,
|
borderRadius: 8,
|
||||||
color: 'var(--color-text-secondary)',
|
color: 'var(--color-text-secondary)',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
@ -18,7 +16,6 @@ const baseLinkStyle: React.CSSProperties = {
|
|||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: 600,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
@ -45,84 +42,33 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<BrokerAccountContext.Provider value={{ accountId, portfolio }}>
|
<BrokerAccountContext.Provider value={{ accountId, portfolio }}>
|
||||||
<Box sx={{ display: 'grid', gap: 3 }}>
|
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||||
<Box
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||||
sx={{
|
<Heading level={1}>{portfolio.data?.account.name || 'Брокерский счёт'}</Heading>
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-end',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 3,
|
|
||||||
minHeight: 72,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-end',
|
|
||||||
gap: 2.5,
|
|
||||||
minWidth: 0,
|
|
||||||
flex: '1 1 auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Heading
|
|
||||||
level={1}
|
|
||||||
style={{ fontSize: 'clamp(28px, 4vw, 42px)', fontWeight: 700, lineHeight: 1.05 }}
|
|
||||||
>
|
|
||||||
{portfolio.data?.account.name || 'Брокерский счёт'}
|
|
||||||
</Heading>
|
|
||||||
{portfolio.data ? (
|
|
||||||
<Box sx={{ flexShrink: 0, display: 'grid', gap: 0.25, pb: 0.25 }}>
|
|
||||||
<Box sx={{ fontSize: 11, fontWeight: 700, color: 'text.secondary', lineHeight: 1 }}>
|
|
||||||
Доходность
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: 22,
|
|
||||||
lineHeight: 1,
|
|
||||||
color:
|
|
||||||
moneyTone(portfolio.data.yields.expectedPercent as number | null) ===
|
|
||||||
'positive'
|
|
||||||
? 'success.main'
|
|
||||||
: 'error.main',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatBrokerPercent(portfolio.data.yields.expectedPercent as number | null)}
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
fontSize: 12,
|
|
||||||
lineHeight: 1.25,
|
|
||||||
color:
|
|
||||||
moneyTone(portfolio.data.yields.daily as number | null) === 'positive'
|
|
||||||
? 'success.main'
|
|
||||||
: 'text.secondary',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
За день:{' '}
|
|
||||||
<Box component="span" sx={{ fontWeight: 700 }}>
|
|
||||||
{formatBrokerMoney(portfolio.data.yields.daily)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ flexShrink: 0, display: 'grid', gap: 1, pb: 0.25 }} aria-hidden="true">
|
|
||||||
<Skeleton height={12} width={74} shape="rounded" />
|
|
||||||
<Skeleton height={28} width={104} shape="rounded" />
|
|
||||||
<Skeleton height={14} width={96} shape="rounded" />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr',
|
||||||
|
gap: 2,
|
||||||
|
'@media (min-width: 720px)': {
|
||||||
|
gridTemplateColumns: 'minmax(150px, 190px) minmax(0, 1fr)',
|
||||||
|
gap: 3,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Box
|
<Box
|
||||||
component="nav"
|
component="nav"
|
||||||
aria-label="Разделы брокерского счёта"
|
aria-label="Разделы брокерского счёта"
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
gap: 0.5,
|
gap: 0.5,
|
||||||
|
'@media (max-width: 719px)': {
|
||||||
|
flexDirection: 'row',
|
||||||
overflowX: 'auto',
|
overflowX: 'auto',
|
||||||
scrollbarWidth: 'thin',
|
scrollbarWidth: 'thin',
|
||||||
pb: 0.5,
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
@ -136,7 +82,6 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
|
|||||||
...baseLinkStyle,
|
...baseLinkStyle,
|
||||||
color: 'var(--color-primary)',
|
color: 'var(--color-primary)',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
background: 'rgba(25, 118, 210, 0.1)',
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -147,6 +92,7 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
<Box sx={{ minWidth: 0 }}>{children}</Box>
|
<Box sx={{ minWidth: 0 }}>{children}</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
</BrokerAccountContext.Provider>
|
</BrokerAccountContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
export { BrokerDashboard } from './ui/BrokerDashboard'
|
|
||||||
export { BrokerDashboardSkeleton } from './ui/BrokerDashboardSkeleton'
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
import dayjs from 'dayjs'
|
|
||||||
|
|
||||||
export const DASHBOARD_EVENT_TYPES = ['dividend', 'coupon', 'maturity', 'offer'] as const
|
|
||||||
export const DASHBOARD_INCOME_TYPES = ['dividend', 'coupon'] as const
|
|
||||||
|
|
||||||
export type DashboardEventType = (typeof DASHBOARD_EVENT_TYPES)[number]
|
|
||||||
export type DashboardIncomeType = (typeof DASHBOARD_INCOME_TYPES)[number]
|
|
||||||
export type DashboardDatePreset = '7d' | '30d' | '90d' | '1y' | 'all'
|
|
||||||
|
|
||||||
export type DashboardFilterState<T extends string> = {
|
|
||||||
from: string
|
|
||||||
to: string
|
|
||||||
types: T[]
|
|
||||||
preset: DashboardDatePreset
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultEventsFilters(): DashboardFilterState<DashboardEventType> {
|
|
||||||
const now = dayjs()
|
|
||||||
return {
|
|
||||||
from: now.subtract(7, 'day').format('YYYY-MM-DD'),
|
|
||||||
to: now.add(7, 'day').format('YYYY-MM-DD'),
|
|
||||||
types: [...DASHBOARD_EVENT_TYPES],
|
|
||||||
preset: '7d',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultIncomeFilters(): DashboardFilterState<DashboardIncomeType> {
|
|
||||||
const now = dayjs()
|
|
||||||
return {
|
|
||||||
from: now.subtract(7, 'day').format('YYYY-MM-DD'),
|
|
||||||
to: now.format('YYYY-MM-DD'),
|
|
||||||
types: [...DASHBOARD_INCOME_TYPES],
|
|
||||||
preset: '7d',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyDatePreset<T extends string>(
|
|
||||||
filters: DashboardFilterState<T>,
|
|
||||||
preset: DashboardDatePreset,
|
|
||||||
): DashboardFilterState<T> {
|
|
||||||
const now = dayjs()
|
|
||||||
const next = { ...filters, preset }
|
|
||||||
if (preset === 'all') {
|
|
||||||
return { ...next, from: '2000-01-01', to: '2099-12-31' }
|
|
||||||
}
|
|
||||||
const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10)
|
|
||||||
const unit = preset === '1y' ? 'year' : 'day'
|
|
||||||
return {
|
|
||||||
...next,
|
|
||||||
from: now.subtract(amount, unit).format('YYYY-MM-DD'),
|
|
||||||
to: now.format('YYYY-MM-DD'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyEventDatePreset(
|
|
||||||
filters: DashboardFilterState<DashboardEventType>,
|
|
||||||
preset: DashboardDatePreset,
|
|
||||||
): DashboardFilterState<DashboardEventType> {
|
|
||||||
const now = dayjs()
|
|
||||||
const next = { ...filters, preset }
|
|
||||||
if (preset === 'all') {
|
|
||||||
return { ...next, from: '2000-01-01', to: '2099-12-31' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10)
|
|
||||||
const unit = preset === '1y' ? 'year' : 'day'
|
|
||||||
return {
|
|
||||||
...next,
|
|
||||||
from: now.subtract(amount, unit).format('YYYY-MM-DD'),
|
|
||||||
to: now.add(amount, unit).format('YYYY-MM-DD'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateDashboardFilters<T extends string>(
|
|
||||||
filters: DashboardFilterState<T>,
|
|
||||||
): string {
|
|
||||||
if (filters.types.length === 0) return 'Выберите хотя бы один тип'
|
|
||||||
if (filters.from && filters.to && dayjs(filters.to).isBefore(dayjs(filters.from))) {
|
|
||||||
return 'Дата окончания не может быть раньше даты начала'
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function incomeTypesToOperationTypes(types: DashboardIncomeType[]): string {
|
|
||||||
const operationTypes = new Set<string>()
|
|
||||||
if (types.includes('dividend')) {
|
|
||||||
operationTypes.add('OPERATION_TYPE_DIVIDEND')
|
|
||||||
operationTypes.add('OPERATION_TYPE_DIV_EXT')
|
|
||||||
}
|
|
||||||
if (types.includes('coupon')) operationTypes.add('OPERATION_TYPE_COUPON')
|
|
||||||
return [...operationTypes].join(',')
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import type { BrokerEventItem } from '@/shared/api'
|
|
||||||
|
|
||||||
export function dashboardValue(value: string | null | undefined): string {
|
|
||||||
return value && value.trim().length > 0 ? value : '—'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function eventTypeLabel(type: BrokerEventItem['type']): string {
|
|
||||||
switch (type) {
|
|
||||||
case 'dividend':
|
|
||||||
return 'Дивиденд'
|
|
||||||
case 'coupon':
|
|
||||||
return 'Купон'
|
|
||||||
case 'maturity':
|
|
||||||
return 'Погашение'
|
|
||||||
case 'offer':
|
|
||||||
return 'Оферта'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function eventStatusLabel(event: BrokerEventItem): string {
|
|
||||||
if (event.source === 'actual') return 'Поступило'
|
|
||||||
return 'Ожидается'
|
|
||||||
}
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import type { BrokerOperation } from '@/shared/api'
|
|
||||||
import {
|
|
||||||
getDashboardIncomeRows,
|
|
||||||
isDashboardIncomeOperation,
|
|
||||||
sumDashboardIncome,
|
|
||||||
} from './dashboardIncome'
|
|
||||||
|
|
||||||
function operation(type: string, value: number | null): BrokerOperation {
|
|
||||||
return {
|
|
||||||
cursor: null,
|
|
||||||
accountId: 'acc-1',
|
|
||||||
id: type,
|
|
||||||
parentOperationId: null,
|
|
||||||
date: '2026-06-01T00:00:00.000Z',
|
|
||||||
type,
|
|
||||||
category: 'income',
|
|
||||||
description: null,
|
|
||||||
name: 'Apple Inc.',
|
|
||||||
state: 'OPERATION_STATE_EXECUTED',
|
|
||||||
instrumentUid: null,
|
|
||||||
figi: null,
|
|
||||||
ticker: 'AAPL',
|
|
||||||
classCode: null,
|
|
||||||
instrumentType: 'share',
|
|
||||||
payment:
|
|
||||||
value === null ? null : { currency: 'RUB', units: String(Math.trunc(value)), nano: 0, value },
|
|
||||||
price: null,
|
|
||||||
commission: null,
|
|
||||||
yield: null,
|
|
||||||
accruedInt: null,
|
|
||||||
quantity: null,
|
|
||||||
quantityDone: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('dashboardIncome', () => {
|
|
||||||
it('detects dividend and coupon operation types', () => {
|
|
||||||
expect(isDashboardIncomeOperation(operation('OPERATION_TYPE_DIVIDEND', 10))).toBe(true)
|
|
||||||
expect(isDashboardIncomeOperation(operation('OPERATION_TYPE_DIV_EXT', 10))).toBe(true)
|
|
||||||
expect(isDashboardIncomeOperation(operation('OPERATION_TYPE_COUPON', 10))).toBe(true)
|
|
||||||
expect(isDashboardIncomeOperation(operation('OPERATION_TYPE_BUY', -10))).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns only displayable income rows with payments', () => {
|
|
||||||
const rows = getDashboardIncomeRows([
|
|
||||||
operation('OPERATION_TYPE_DIVIDEND', 10),
|
|
||||||
operation('OPERATION_TYPE_BUY', -10),
|
|
||||||
operation('OPERATION_TYPE_COUPON', 5),
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(rows).toHaveLength(2)
|
|
||||||
expect(rows[0].typeLabel).toBe('Дивиденд')
|
|
||||||
expect(rows[0].amount.value).toBe(10)
|
|
||||||
expect(rows[1].typeLabel).toBe('Купон')
|
|
||||||
expect(rows[1].amount.value).toBe(5)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('marks OPERATION_TYPE_DIV_EXT as "Дивиденд (внешний)"', () => {
|
|
||||||
const rows = getDashboardIncomeRows([operation('OPERATION_TYPE_DIV_EXT', 12)])
|
|
||||||
|
|
||||||
expect(rows).toHaveLength(1)
|
|
||||||
expect(rows[0].typeLabel).toBe('Дивиденд (внешний)')
|
|
||||||
expect(rows[0].amount.value).toBe(12)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('splits instrument into main and subtitle', () => {
|
|
||||||
const rows = getDashboardIncomeRows([operation('OPERATION_TYPE_DIVIDEND', 10)])
|
|
||||||
|
|
||||||
expect(rows[0].instrumentMain).toBe('AAPL')
|
|
||||||
expect(rows[0].instrumentSubtitle).toBe('Apple Inc.')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to "—" with no subtitle when ticker, name, and description are missing', () => {
|
|
||||||
const base = operation('OPERATION_TYPE_COUPON', 7)
|
|
||||||
const rows = getDashboardIncomeRows([{ ...base, ticker: null, name: null, description: null }])
|
|
||||||
|
|
||||||
expect(rows[0].instrumentMain).toBe('—')
|
|
||||||
expect(rows[0].instrumentSubtitle).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not duplicate subtitle when name equals ticker', () => {
|
|
||||||
const base = operation('OPERATION_TYPE_COUPON', 3)
|
|
||||||
const rows = getDashboardIncomeRows([{ ...base, ticker: 'AAPL', name: 'AAPL' }])
|
|
||||||
|
|
||||||
expect(rows[0].instrumentMain).toBe('AAPL')
|
|
||||||
expect(rows[0].instrumentSubtitle).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sums displayed income rows by currency', () => {
|
|
||||||
const rows = getDashboardIncomeRows([
|
|
||||||
operation('OPERATION_TYPE_DIVIDEND', 10),
|
|
||||||
operation('OPERATION_TYPE_COUPON', 2.5),
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(sumDashboardIncome(rows)).toEqual({ currency: 'RUB', value: 12.5 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null for empty rows sum', () => {
|
|
||||||
expect(sumDashboardIncome([])).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty array for empty input', () => {
|
|
||||||
expect(getDashboardIncomeRows([])).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty array when no income operations present', () => {
|
|
||||||
const rows = getDashboardIncomeRows([
|
|
||||||
operation('OPERATION_TYPE_BUY', -10),
|
|
||||||
operation('OPERATION_TYPE_SELL', 20),
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(rows).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
import type { BrokerMoney, BrokerOperation } from '@/shared/api'
|
|
||||||
import { instrumentDisplay } from './dashboardVisual'
|
|
||||||
|
|
||||||
const INCOME_TYPES = new Set([
|
|
||||||
'OPERATION_TYPE_DIVIDEND',
|
|
||||||
'OPERATION_TYPE_DIV_EXT',
|
|
||||||
'OPERATION_TYPE_COUPON',
|
|
||||||
])
|
|
||||||
|
|
||||||
export type DashboardIncomeTypeLabel = 'Дивиденд' | 'Дивиденд (внешний)' | 'Купон'
|
|
||||||
|
|
||||||
export type DashboardIncomeRow = {
|
|
||||||
id: string
|
|
||||||
date: string | null
|
|
||||||
instrumentMain: string
|
|
||||||
instrumentSubtitle: string | null
|
|
||||||
typeLabel: DashboardIncomeTypeLabel
|
|
||||||
amount: BrokerMoney
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isDashboardIncomeOperation(operation: BrokerOperation): boolean {
|
|
||||||
return INCOME_TYPES.has(operation.type) && operation.payment !== null
|
|
||||||
}
|
|
||||||
|
|
||||||
function typeLabel(type: string): DashboardIncomeTypeLabel {
|
|
||||||
if (type === 'OPERATION_TYPE_COUPON') return 'Купон'
|
|
||||||
if (type === 'OPERATION_TYPE_DIV_EXT') return 'Дивиденд (внешний)'
|
|
||||||
return 'Дивиденд'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDashboardIncomeRows(operations: BrokerOperation[]): DashboardIncomeRow[] {
|
|
||||||
return operations.filter(isDashboardIncomeOperation).map((operation) => {
|
|
||||||
const { main, subtitle } = instrumentDisplay({
|
|
||||||
ticker: operation.ticker,
|
|
||||||
name: operation.name,
|
|
||||||
description: operation.description,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
id: String(operation.id ?? operation.cursor ?? `${operation.type}-${operation.date}`),
|
|
||||||
date: typeof operation.date === 'string' ? operation.date : null,
|
|
||||||
instrumentMain: main,
|
|
||||||
instrumentSubtitle: subtitle,
|
|
||||||
typeLabel: typeLabel(operation.type),
|
|
||||||
amount: operation.payment!,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sumDashboardIncome(
|
|
||||||
rows: DashboardIncomeRow[],
|
|
||||||
): { currency: string; value: number } | null {
|
|
||||||
if (rows.length === 0) return null
|
|
||||||
const currency = rows[0].amount.currency
|
|
||||||
const value = rows.reduce((sum, row) => sum + row.amount.value, 0)
|
|
||||||
return { currency, value }
|
|
||||||
}
|
|
||||||
@ -1,159 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import type { BrokerMoney } from '@/shared/api'
|
|
||||||
import {
|
|
||||||
eventTypeTone,
|
|
||||||
formatDashboardCurrency,
|
|
||||||
incomeTypeTone,
|
|
||||||
instrumentDisplay,
|
|
||||||
moneyTone,
|
|
||||||
moneyToneToColor,
|
|
||||||
} from './dashboardVisual'
|
|
||||||
|
|
||||||
function money(currency: string, value: number): BrokerMoney {
|
|
||||||
return { currency, units: String(Math.trunc(value)), nano: 0, value }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('formatDashboardCurrency', () => {
|
|
||||||
it('renders RUB with the ₽ symbol via the shared formatter', () => {
|
|
||||||
const result = formatDashboardCurrency(money('RUB', 1234.56))
|
|
||||||
expect(result).toContain('₽')
|
|
||||||
expect(result).not.toContain('RUB')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders known non-RUB currencies via the shared formatter', () => {
|
|
||||||
const result = formatDashboardCurrency(money('USD', 42))
|
|
||||||
expect(result).toContain('$')
|
|
||||||
expect(result).not.toContain('USD')
|
|
||||||
expect(result).not.toContain('₽')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to currency code for unknown currencies', () => {
|
|
||||||
const result = formatDashboardCurrency(money('XYZ', 100))
|
|
||||||
expect(result).toContain('XYZ')
|
|
||||||
expect(result).not.toContain('¤')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to currency code via the value-only shape', () => {
|
|
||||||
const result = formatDashboardCurrency({ currency: 'XYZ', value: 9.99 })
|
|
||||||
expect(result).toContain('XYZ')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns "—" for null or undefined', () => {
|
|
||||||
expect(formatDashboardCurrency(null)).toBe('—')
|
|
||||||
expect(formatDashboardCurrency(undefined)).toBe('—')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('moneyTone', () => {
|
|
||||||
it('returns positive for positive actual values', () => {
|
|
||||||
expect(moneyTone(10, 'actual')).toBe('positive')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('treats undefined source as actual and returns positive', () => {
|
|
||||||
expect(moneyTone(10)).toBe('positive')
|
|
||||||
expect(moneyTone(10, undefined)).toBe('positive')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns planned for positive forecast values', () => {
|
|
||||||
expect(moneyTone(10, 'forecast')).toBe('planned')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns negative for negative values regardless of source', () => {
|
|
||||||
expect(moneyTone(-10, 'actual')).toBe('negative')
|
|
||||||
expect(moneyTone(-10, 'forecast')).toBe('negative')
|
|
||||||
expect(moneyTone(-0.01)).toBe('negative')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns neutral for zero', () => {
|
|
||||||
expect(moneyTone(0)).toBe('neutral')
|
|
||||||
expect(moneyTone(0, 'actual')).toBe('neutral')
|
|
||||||
expect(moneyTone(0, 'forecast')).toBe('neutral')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns neutral for null and undefined', () => {
|
|
||||||
expect(moneyTone(null)).toBe('neutral')
|
|
||||||
expect(moneyTone(undefined)).toBe('neutral')
|
|
||||||
expect(moneyTone(null, 'forecast')).toBe('neutral')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('eventTypeTone', () => {
|
|
||||||
it('maps each event type to a stable tone', () => {
|
|
||||||
expect(eventTypeTone('dividend')).toBe('success')
|
|
||||||
expect(eventTypeTone('coupon')).toBe('info')
|
|
||||||
expect(eventTypeTone('maturity')).toBe('warning')
|
|
||||||
expect(eventTypeTone('offer')).toBe('neutral')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('moneyToneToColor', () => {
|
|
||||||
it('maps each MoneyTone to a stable MUI color', () => {
|
|
||||||
expect(moneyToneToColor('positive')).toBe('success.main')
|
|
||||||
expect(moneyToneToColor('negative')).toBe('error.main')
|
|
||||||
expect(moneyToneToColor('planned')).toBe('text.disabled')
|
|
||||||
expect(moneyToneToColor('neutral')).toBe('text.disabled')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('incomeTypeTone', () => {
|
|
||||||
it('maps each income label to a stable tone matching HTML parity (coupon=info)', () => {
|
|
||||||
expect(incomeTypeTone('Дивиденд')).toBe('success')
|
|
||||||
expect(incomeTypeTone('Дивиденд (внешний)')).toBe('success')
|
|
||||||
expect(incomeTypeTone('Купон')).toBe('info')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('instrumentDisplay', () => {
|
|
||||||
it('uses ticker as main and skips subtitle when ticker is the only field', () => {
|
|
||||||
expect(instrumentDisplay({ ticker: 'AAPL' })).toEqual({ main: 'AAPL', subtitle: null })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses ticker as main and name as subtitle when both are present', () => {
|
|
||||||
expect(instrumentDisplay({ ticker: 'AAPL', name: 'Apple Inc.' })).toEqual({
|
|
||||||
main: 'AAPL',
|
|
||||||
subtitle: 'Apple Inc.',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('prefers name over description when both are provided alongside ticker', () => {
|
|
||||||
expect(
|
|
||||||
instrumentDisplay({ ticker: 'AAPL', name: 'Apple Inc.', description: 'Apple computer' }),
|
|
||||||
).toEqual({ main: 'AAPL', subtitle: 'Apple Inc.' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses name as main and skips subtitle when no ticker is provided', () => {
|
|
||||||
expect(instrumentDisplay({ name: 'Apple Inc.' })).toEqual({
|
|
||||||
main: 'Apple Inc.',
|
|
||||||
subtitle: null,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses description as main when neither ticker nor name are provided', () => {
|
|
||||||
expect(instrumentDisplay({ description: 'Apple computer' })).toEqual({
|
|
||||||
main: 'Apple computer',
|
|
||||||
subtitle: null,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns "—" as main when no fields are provided', () => {
|
|
||||||
expect(instrumentDisplay({})).toEqual({ main: '—', subtitle: null })
|
|
||||||
expect(instrumentDisplay({ ticker: null, name: null, description: null })).toEqual({
|
|
||||||
main: '—',
|
|
||||||
subtitle: null,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not duplicate subtitle when name equals ticker', () => {
|
|
||||||
expect(instrumentDisplay({ ticker: 'AAPL', name: 'AAPL' })).toEqual({
|
|
||||||
main: 'AAPL',
|
|
||||||
subtitle: null,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('treats empty strings as missing', () => {
|
|
||||||
expect(instrumentDisplay({ ticker: '', name: '', description: '' })).toEqual({
|
|
||||||
main: '—',
|
|
||||||
subtitle: null,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
import type { BrokerEventItem, BrokerMoney } from '@/shared/api'
|
|
||||||
import { formatBrokerCurrencyValue } from '@/shared/lib/formatters'
|
|
||||||
import type { DashboardIncomeTypeLabel } from './dashboardIncome'
|
|
||||||
|
|
||||||
export type MoneyTone = 'positive' | 'negative' | 'planned' | 'neutral'
|
|
||||||
|
|
||||||
export type TypeTone = 'neutral' | 'info' | 'success' | 'warning'
|
|
||||||
|
|
||||||
export type DashboardMoneyLike = BrokerMoney | { currency: string; value: number }
|
|
||||||
|
|
||||||
export type MoneySource = 'actual' | 'forecast'
|
|
||||||
|
|
||||||
export function moneyTone(value: number | null | undefined, source?: MoneySource): MoneyTone {
|
|
||||||
if (value === null || value === undefined) return 'neutral'
|
|
||||||
if (value === 0) return 'neutral'
|
|
||||||
if (value < 0) return 'negative'
|
|
||||||
return source === 'forecast' ? 'planned' : 'positive'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moneyToneToColor(tone: MoneyTone): string {
|
|
||||||
switch (tone) {
|
|
||||||
case 'positive':
|
|
||||||
return 'success.main'
|
|
||||||
case 'negative':
|
|
||||||
return 'error.main'
|
|
||||||
case 'planned':
|
|
||||||
case 'neutral':
|
|
||||||
return 'text.disabled'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDashboardCurrency(money: DashboardMoneyLike | null | undefined): string {
|
|
||||||
if (!money) return '—'
|
|
||||||
try {
|
|
||||||
const formatted = formatBrokerCurrencyValue(money.currency, money.value)
|
|
||||||
if (formatted.includes('¤')) {
|
|
||||||
return formatCurrencyCodeFallback(money.value, money.currency)
|
|
||||||
}
|
|
||||||
return formatted
|
|
||||||
} catch {
|
|
||||||
return formatCurrencyCodeFallback(money.value, money.currency)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCurrencyCodeFallback(value: number, currency: string): string {
|
|
||||||
const numberPart = new Intl.NumberFormat('ru-RU', {
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
}).format(value)
|
|
||||||
return `${numberPart}\u00a0${currency}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function eventTypeTone(type: BrokerEventItem['type']): TypeTone {
|
|
||||||
switch (type) {
|
|
||||||
case 'dividend':
|
|
||||||
return 'success'
|
|
||||||
case 'coupon':
|
|
||||||
return 'info'
|
|
||||||
case 'maturity':
|
|
||||||
return 'warning'
|
|
||||||
case 'offer':
|
|
||||||
return 'neutral'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function incomeTypeTone(label: DashboardIncomeTypeLabel): TypeTone {
|
|
||||||
switch (label) {
|
|
||||||
case 'Дивиденд':
|
|
||||||
case 'Дивиденд (внешний)':
|
|
||||||
return 'success'
|
|
||||||
case 'Купон':
|
|
||||||
return 'info'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function eventStatusTone(source: BrokerEventItem['source']): TypeTone {
|
|
||||||
return source === 'actual' ? 'success' : 'neutral'
|
|
||||||
}
|
|
||||||
|
|
||||||
function nonEmpty(value: string | null | undefined): string | null {
|
|
||||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function instrumentDisplay(input: {
|
|
||||||
ticker?: string | null
|
|
||||||
name?: string | null
|
|
||||||
description?: string | null
|
|
||||||
}): { main: string; subtitle: string | null } {
|
|
||||||
const ticker = nonEmpty(input.ticker)
|
|
||||||
const name = nonEmpty(input.name)
|
|
||||||
const description = nonEmpty(input.description)
|
|
||||||
|
|
||||||
const main = ticker ?? name ?? description ?? '—'
|
|
||||||
const subtitleCandidate = name ?? description
|
|
||||||
const subtitle = subtitleCandidate && subtitleCandidate !== main ? subtitleCandidate : null
|
|
||||||
|
|
||||||
return { main, subtitle }
|
|
||||||
}
|
|
||||||
@ -1,316 +0,0 @@
|
|||||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
|
|
||||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
|
|
||||||
import { render, screen, within } from '@testing-library/react'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import type { BrokerOperation, BrokerPortfolio } from '@/shared/api'
|
|
||||||
import { BrokerDashboard } from './BrokerDashboard'
|
|
||||||
|
|
||||||
const hookMocks = vi.hoisted(() => ({
|
|
||||||
useBrokerOperations: vi.fn(),
|
|
||||||
useBrokerPortfolioHistory: vi.fn(() => ({
|
|
||||||
data: undefined,
|
|
||||||
isLoading: false,
|
|
||||||
isError: false,
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@tanstack/react-router', () => ({
|
|
||||||
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
|
|
||||||
<a href={to}>{children}</a>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/entities/broker-analytics', () => ({
|
|
||||||
useBrokerAnalytics: () => ({
|
|
||||||
data: {
|
|
||||||
totalDeposits: 1000,
|
|
||||||
totalWithdrawn: 250,
|
|
||||||
netInvested: -150,
|
|
||||||
totalDividends: 75,
|
|
||||||
totalCoupons: 0,
|
|
||||||
totalReceived: 90,
|
|
||||||
totalFees: -50,
|
|
||||||
totalTaxesPaid: -13,
|
|
||||||
totalReturnPercent: 4.44,
|
|
||||||
currency: 'RUB',
|
|
||||||
},
|
|
||||||
isLoading: false,
|
|
||||||
isError: false,
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/entities/broker-operation', () => ({
|
|
||||||
useBrokerOperations: hookMocks.useBrokerOperations,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/entities/broker-account', () => ({
|
|
||||||
useBrokerPortfolioHistory: hookMocks.useBrokerPortfolioHistory,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const portfolio: BrokerPortfolio = {
|
|
||||||
account: {
|
|
||||||
id: 'acc-1',
|
|
||||||
name: 'Основной счёт',
|
|
||||||
type: 'brokerage',
|
|
||||||
status: 'open',
|
|
||||||
openedAt: null,
|
|
||||||
accessLevel: null,
|
|
||||||
},
|
|
||||||
positionCounts: { shares: 2, bonds: 1, etf: 0, other: 0 },
|
|
||||||
totals: {
|
|
||||||
shares: null,
|
|
||||||
bonds: null,
|
|
||||||
etf: null,
|
|
||||||
currencies: null,
|
|
||||||
futures: null,
|
|
||||||
options: null,
|
|
||||||
structuredProducts: null,
|
|
||||||
dfa: null,
|
|
||||||
portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
|
|
||||||
},
|
|
||||||
yields: { expectedPercent: null, daily: null, dailyPercent: null },
|
|
||||||
cash: [],
|
|
||||||
blockedCash: [],
|
|
||||||
asOf: '2026-06-26T00:00:00.000Z',
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderWithProviders(ui: ReactNode) {
|
|
||||||
return render(<LocalizationProvider dateAdapter={AdapterDayjs}>{ui}</LocalizationProvider>)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOperation(overrides: Partial<BrokerOperation> = {}): BrokerOperation {
|
|
||||||
return {
|
|
||||||
cursor: null,
|
|
||||||
accountId: 'acc-1',
|
|
||||||
id: 'op-1',
|
|
||||||
parentOperationId: null,
|
|
||||||
date: '2026-06-15T00:00:00.000Z',
|
|
||||||
type: 'OPERATION_TYPE_COUPON',
|
|
||||||
category: 'income',
|
|
||||||
description: null,
|
|
||||||
name: 'ОФЗ 26241',
|
|
||||||
state: 'OPERATION_STATE_EXECUTED',
|
|
||||||
instrumentUid: null,
|
|
||||||
figi: null,
|
|
||||||
ticker: 'SU26249RMFS1',
|
|
||||||
classCode: null,
|
|
||||||
instrumentType: 'bond',
|
|
||||||
payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 },
|
|
||||||
price: null,
|
|
||||||
commission: null,
|
|
||||||
yield: null,
|
|
||||||
accruedInt: null,
|
|
||||||
quantity: null,
|
|
||||||
quantityDone: null,
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockOperationsLoaded(items: BrokerOperation[]) {
|
|
||||||
hookMocks.useBrokerOperations.mockReturnValue({
|
|
||||||
data: {
|
|
||||||
accountId: 'acc-1',
|
|
||||||
items,
|
|
||||||
nextCursor: null,
|
|
||||||
hasNext: false,
|
|
||||||
asOf: '2026-06-26T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
isLoading: false,
|
|
||||||
isError: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('BrokerDashboard', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
hookMocks.useBrokerOperations.mockReturnValue({
|
|
||||||
data: {
|
|
||||||
accountId: 'acc-1',
|
|
||||||
items: [],
|
|
||||||
nextCursor: null,
|
|
||||||
hasNext: false,
|
|
||||||
asOf: '2026-06-26T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
isLoading: false,
|
|
||||||
isError: false,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the dashboard sections in spec order', () => {
|
|
||||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
|
||||||
|
|
||||||
const cards = screen.getAllByRole('region')
|
|
||||||
const labels = cards.map((c) => c.getAttribute('aria-label'))
|
|
||||||
expect(labels).toEqual([
|
|
||||||
'Стоимость портфеля за 6 месяцев',
|
|
||||||
'Аналитика доходности',
|
|
||||||
'Структура',
|
|
||||||
'Последние события',
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders analytics card with the ₽ symbol and no "RUB" code', () => {
|
|
||||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
|
||||||
|
|
||||||
const analytics = screen.getByLabelText('Аналитика доходности')
|
|
||||||
const analyticsText = analytics.textContent ?? ''
|
|
||||||
expect(analyticsText).toContain('₽')
|
|
||||||
expect(analyticsText).not.toContain('RUB')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies positive, negative and neutral tones to analytics values', () => {
|
|
||||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
|
||||||
|
|
||||||
const deposits = screen.getByTestId('dashboard-analytics-totalDeposits')
|
|
||||||
expect(deposits.getAttribute('data-tone')).toBe('positive')
|
|
||||||
expect(deposits.textContent).toContain('1\u00a0000,00')
|
|
||||||
expect(deposits.textContent).toContain('₽')
|
|
||||||
|
|
||||||
const withdrawn = screen.getByTestId('dashboard-analytics-totalWithdrawn')
|
|
||||||
expect(withdrawn.getAttribute('data-tone')).toBe('negative')
|
|
||||||
expect(withdrawn.textContent).toMatch(/^[−-]250,00/)
|
|
||||||
|
|
||||||
const dividends = screen.getByTestId('dashboard-analytics-totalDividends')
|
|
||||||
expect(dividends.getAttribute('data-tone')).toBe('positive')
|
|
||||||
expect(dividends.textContent).toContain('75,00')
|
|
||||||
|
|
||||||
const coupons = screen.getByTestId('dashboard-analytics-totalCoupons')
|
|
||||||
expect(coupons.getAttribute('data-tone')).toBe('neutral')
|
|
||||||
expect(coupons.textContent).toContain('0,00')
|
|
||||||
|
|
||||||
const fees = screen.getByTestId('dashboard-analytics-totalFees')
|
|
||||||
expect(fees.getAttribute('data-tone')).toBe('negative')
|
|
||||||
expect(fees.textContent).toMatch(/^[−-]50,00/)
|
|
||||||
|
|
||||||
const taxes = screen.getByTestId('dashboard-analytics-totalTaxesPaid')
|
|
||||||
expect(taxes.getAttribute('data-tone')).toBe('negative')
|
|
||||||
expect(taxes.textContent).toMatch(/^[−-]13,00/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows skeleton table while events are loading', () => {
|
|
||||||
hookMocks.useBrokerOperations.mockReturnValue({
|
|
||||||
data: undefined,
|
|
||||||
isLoading: true,
|
|
||||||
isError: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
|
||||||
|
|
||||||
const skeletons = screen.getAllByTestId('dashboard-table-skeleton')
|
|
||||||
expect(skeletons.length).toBeGreaterThanOrEqual(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders dashboard sections when data is loaded', () => {
|
|
||||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
|
||||||
|
|
||||||
expect(screen.getByText('Последние события')).toBeInTheDocument()
|
|
||||||
expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders events table thead with semantic column headers', () => {
|
|
||||||
mockOperationsLoaded([
|
|
||||||
buildOperation({
|
|
||||||
id: 'op-1',
|
|
||||||
category: 'income',
|
|
||||||
type: 'OPERATION_TYPE_DIVIDEND',
|
|
||||||
ticker: 'IRAO',
|
|
||||||
name: 'Интер РАО',
|
|
||||||
payment: { currency: 'RUB', units: '649', nano: 250000000, value: 649.25 },
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
|
||||||
|
|
||||||
const eventsTable = screen.getByLabelText('Таблица последних событий брокерского счёта')
|
|
||||||
expect(eventsTable.querySelector('thead')).not.toBeNull()
|
|
||||||
const headers = within(eventsTable).getAllByRole('columnheader')
|
|
||||||
expect(headers.map((h) => h.textContent)).toEqual(['Дата', 'Инструмент', 'Тип', 'Сумма'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders event type badge, instrument subtitle for loaded operations', () => {
|
|
||||||
mockOperationsLoaded([
|
|
||||||
buildOperation({
|
|
||||||
id: 'op-div',
|
|
||||||
category: 'income',
|
|
||||||
type: 'OPERATION_TYPE_DIVIDEND',
|
|
||||||
ticker: 'IRAO',
|
|
||||||
name: 'Интер РАО',
|
|
||||||
payment: { currency: 'RUB', units: '649', nano: 250000000, value: 649.25 },
|
|
||||||
}),
|
|
||||||
buildOperation({
|
|
||||||
id: 'op-coupon',
|
|
||||||
category: 'income',
|
|
||||||
type: 'OPERATION_TYPE_COUPON',
|
|
||||||
ticker: 'SU26249RMFS1',
|
|
||||||
name: 'ОФЗ 26241',
|
|
||||||
payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 },
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
|
||||||
|
|
||||||
const rows = screen.getAllByTestId('dashboard-events-row')
|
|
||||||
expect(rows).toHaveLength(2)
|
|
||||||
|
|
||||||
const subtitles = screen.getAllByTestId('dashboard-events-instrument-subtitle')
|
|
||||||
expect(subtitles.map((el) => el.textContent)).toEqual(['IRAO', 'SU26249RMFS1'])
|
|
||||||
|
|
||||||
const amounts = screen.getAllByTestId('dashboard-events-amount')
|
|
||||||
expect(amounts[0].getAttribute('data-tone')).toBe('positive')
|
|
||||||
expect(amounts[0].textContent).toContain('+649,25')
|
|
||||||
expect(amounts[1].getAttribute('data-tone')).toBe('positive')
|
|
||||||
expect(amounts[1].textContent).toContain('+109,70')
|
|
||||||
|
|
||||||
const eventsTable = screen.getByLabelText('Таблица последних событий брокерского счёта')
|
|
||||||
expect(within(eventsTable).getByText('Дивиденд')).toBeInTheDocument()
|
|
||||||
expect(within(eventsTable).getByText('Купон')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses negative tone when operation payment is negative', () => {
|
|
||||||
mockOperationsLoaded([
|
|
||||||
buildOperation({
|
|
||||||
id: 'op-neg',
|
|
||||||
category: 'fee',
|
|
||||||
type: 'OPERATION_TYPE_BROKER_FEE',
|
|
||||||
ticker: null,
|
|
||||||
name: 'Комиссия брокера',
|
|
||||||
payment: { currency: 'RUB', units: '0', nano: 0, value: -87.0 },
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
|
||||||
|
|
||||||
const [amount] = screen.getAllByTestId('dashboard-events-amount')
|
|
||||||
expect(amount.getAttribute('data-tone')).toBe('negative')
|
|
||||||
expect(amount.textContent).toContain('87,00')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders events amounts with the ₽ symbol and no "RUB" code', () => {
|
|
||||||
mockOperationsLoaded([
|
|
||||||
buildOperation({
|
|
||||||
id: 'op-coupon',
|
|
||||||
category: 'income',
|
|
||||||
type: 'OPERATION_TYPE_COUPON',
|
|
||||||
ticker: 'SU26249RMFS1',
|
|
||||||
name: 'ОФЗ 26241',
|
|
||||||
payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 },
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
|
||||||
|
|
||||||
const eventsTable = screen.getByLabelText('Таблица последних событий брокерского счёта')
|
|
||||||
expect(eventsTable.textContent ?? '').toContain('₽')
|
|
||||||
expect(eventsTable.textContent ?? '').not.toContain('RUB')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sends correct params to useBrokerOperations for latest events', () => {
|
|
||||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
|
||||||
|
|
||||||
expect(hookMocks.useBrokerOperations).toHaveBeenCalledWith(
|
|
||||||
'acc-1',
|
|
||||||
{ categories: 'income,tax,fee', limit: 100 },
|
|
||||||
{ enabled: true },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import { Box } from '@mui/material'
|
|
||||||
import { useBrokerPortfolioHistory } from '@/entities/broker-account'
|
|
||||||
import { useBrokerAnalytics } from '@/entities/broker-analytics'
|
|
||||||
import { useBrokerOperations } from '@/entities/broker-operation'
|
|
||||||
import type { BrokerPortfolio } from '@/shared/api'
|
|
||||||
import { BrokerDashboardAllocationCard } from './BrokerDashboardAllocationCard'
|
|
||||||
import { BrokerDashboardAnalyticsCard } from './BrokerDashboardAnalyticsCard'
|
|
||||||
import { BrokerDashboardEventsCard } from './BrokerDashboardEventsCard'
|
|
||||||
import { BrokerPortfolioHistoryCard } from './BrokerPortfolioHistoryCard'
|
|
||||||
|
|
||||||
export function BrokerDashboard({
|
|
||||||
accountId,
|
|
||||||
portfolio,
|
|
||||||
}: {
|
|
||||||
accountId: string
|
|
||||||
portfolio: BrokerPortfolio
|
|
||||||
}) {
|
|
||||||
const analytics = useBrokerAnalytics(accountId)
|
|
||||||
const portfolioHistory = useBrokerPortfolioHistory(accountId)
|
|
||||||
|
|
||||||
const eventsOps = useBrokerOperations(
|
|
||||||
accountId,
|
|
||||||
{ categories: 'income,tax,fee', limit: 100 },
|
|
||||||
{ enabled: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'grid', gap: 3 }}>
|
|
||||||
<BrokerPortfolioHistoryCard
|
|
||||||
data={portfolioHistory.data}
|
|
||||||
portfolioValue={portfolio.totals.portfolio ?? undefined}
|
|
||||||
isLoading={portfolioHistory.isLoading}
|
|
||||||
isError={portfolioHistory.isError}
|
|
||||||
/>
|
|
||||||
<BrokerDashboardAnalyticsCard
|
|
||||||
data={analytics.data}
|
|
||||||
portfolioValue={portfolio.totals.portfolio ?? undefined}
|
|
||||||
isLoading={analytics.isLoading}
|
|
||||||
isError={analytics.isError}
|
|
||||||
/>
|
|
||||||
<BrokerDashboardAllocationCard portfolio={portfolio} />
|
|
||||||
<BrokerDashboardEventsCard
|
|
||||||
accountId={accountId}
|
|
||||||
data={eventsOps.data?.items ?? []}
|
|
||||||
isLoading={eventsOps.isLoading}
|
|
||||||
isError={eventsOps.isError}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
import { Text } from '@moex-vibe/design-system'
|
|
||||||
import { Box } from '@mui/material'
|
|
||||||
import { buildBrokerAllocation } from '@/entities/broker-position'
|
|
||||||
import type { BrokerPortfolio } from '@/shared/api'
|
|
||||||
import { formatBrokerCurrencyValue, formatBrokerMoney } from '@/shared/lib/formatters'
|
|
||||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
|
||||||
|
|
||||||
const ALLOCATION_COLORS: Record<string, string> = {
|
|
||||||
shares: '#4969f5',
|
|
||||||
bonds: '#e5a33c',
|
|
||||||
cash: '#7b63cf',
|
|
||||||
}
|
|
||||||
|
|
||||||
const VISIBLE_SECTORS = new Set(['shares', 'bonds', 'cash'])
|
|
||||||
|
|
||||||
export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: BrokerPortfolio }) {
|
|
||||||
const { total, sectors, negative } = buildBrokerAllocation(portfolio)
|
|
||||||
const currency =
|
|
||||||
total >= 0
|
|
||||||
? (portfolio.totals.portfolio?.currency ??
|
|
||||||
Object.values(portfolio.totals).find((t) => t?.currency)?.currency ??
|
|
||||||
'RUB')
|
|
||||||
: 'RUB'
|
|
||||||
|
|
||||||
const visibleSectors = sectors.filter((s) => VISIBLE_SECTORS.has(s.key))
|
|
||||||
const visibleNegative = negative.filter((n) => VISIBLE_SECTORS.has(n.key))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BrokerDashboardCard title="Структура">
|
|
||||||
<Box sx={{ fontWeight: 700, mb: 1.5, lineHeight: 1.25 }}>
|
|
||||||
{formatBrokerMoney(portfolio.totals.portfolio)}
|
|
||||||
</Box>
|
|
||||||
{visibleSectors.length === 0 && visibleNegative.length === 0 ? (
|
|
||||||
<Text tone="muted">Нет данных для распределения</Text>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ display: 'grid', gap: 1.5 }}>
|
|
||||||
{visibleSectors.map((sector) => (
|
|
||||||
<Box key={sector.key}>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
|
||||||
<Text variant="body">{sector.label}</Text>
|
|
||||||
<Text variant="body" tone="secondary">
|
|
||||||
{formatBrokerCurrencyValue(currency, sector.value)} · {sector.percent.toFixed(1)}%
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: 10,
|
|
||||||
borderRadius: 5,
|
|
||||||
bgcolor: 'grey.200',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: `${sector.percent}%`,
|
|
||||||
height: '100%',
|
|
||||||
bgcolor: ALLOCATION_COLORS[sector.key] ?? '#aeb6c5',
|
|
||||||
borderRadius: 5,
|
|
||||||
transition: 'width 0.3s',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
{visibleNegative.length > 0 && (
|
|
||||||
<Box sx={{ mt: 1 }}>
|
|
||||||
{visibleNegative.map((item) => (
|
|
||||||
<Box key={item.key} sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Text variant="body">{item.label}:</Text>
|
|
||||||
<Text variant="body" tone="negative">
|
|
||||||
{formatBrokerCurrencyValue(currency, item.value)}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</BrokerDashboardCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,258 +0,0 @@
|
|||||||
import { Skeleton, Text } from '@moex-vibe/design-system'
|
|
||||||
import { Box } from '@mui/material'
|
|
||||||
import type { BrokerAnalytics, BrokerMoney } from '@/shared/api'
|
|
||||||
import { formatDashboardCurrency, type MoneyTone, moneyTone } from '../lib/dashboardVisual'
|
|
||||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
|
||||||
|
|
||||||
type AnalyticsField =
|
|
||||||
| 'totalDeposits'
|
|
||||||
| 'totalWithdrawn'
|
|
||||||
| 'totalDividends'
|
|
||||||
| 'totalCoupons'
|
|
||||||
| 'totalFees'
|
|
||||||
| 'totalTaxesPaid'
|
|
||||||
|
|
||||||
const ANALYTICS_METRICS: readonly {
|
|
||||||
field: AnalyticsField
|
|
||||||
label: string
|
|
||||||
testId: string
|
|
||||||
}[] = [
|
|
||||||
{ field: 'totalDeposits', label: 'Пополнения', testId: 'dashboard-analytics-totalDeposits' },
|
|
||||||
{ field: 'totalWithdrawn', label: 'Выводы', testId: 'dashboard-analytics-totalWithdrawn' },
|
|
||||||
{ field: 'totalDividends', label: 'Дивиденды', testId: 'dashboard-analytics-totalDividends' },
|
|
||||||
{ field: 'totalCoupons', label: 'Купоны', testId: 'dashboard-analytics-totalCoupons' },
|
|
||||||
{ field: 'totalFees', label: 'Комиссия', testId: 'dashboard-analytics-totalFees' },
|
|
||||||
{
|
|
||||||
field: 'totalTaxesPaid',
|
|
||||||
label: 'Уплаченные налоги',
|
|
||||||
testId: 'dashboard-analytics-totalTaxesPaid',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function analyticsTone(field: AnalyticsField, value: number): MoneyTone {
|
|
||||||
if (field === 'totalFees' || field === 'totalTaxesPaid') return 'negative'
|
|
||||||
if (field === 'totalWithdrawn') {
|
|
||||||
return value > 0 ? 'negative' : moneyTone(value)
|
|
||||||
}
|
|
||||||
return moneyTone(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function analyticsDisplay(field: AnalyticsField, value: number, currency: string): string {
|
|
||||||
if (field === 'totalFees' || field === 'totalTaxesPaid') {
|
|
||||||
const formatted = formatDashboardCurrency({ currency, value: Math.abs(value) })
|
|
||||||
return `\u2212${formatted}`
|
|
||||||
}
|
|
||||||
const formatted = formatDashboardCurrency({ currency, value })
|
|
||||||
if (field === 'totalWithdrawn' && value > 0) return `\u2212${formatted}`
|
|
||||||
return formatted
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BrokerDashboardAnalyticsCard({
|
|
||||||
data,
|
|
||||||
portfolioValue,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
}: {
|
|
||||||
data: BrokerAnalytics | undefined
|
|
||||||
portfolioValue?: BrokerMoney
|
|
||||||
isLoading: boolean
|
|
||||||
isError: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<BrokerDashboardCard title="Аналитика доходности">
|
|
||||||
{isError ? (
|
|
||||||
<Text tone="negative">Не удалось загрузить аналитику</Text>
|
|
||||||
) : isLoading ? (
|
|
||||||
<Box sx={{ display: 'grid', gap: 2 }} aria-hidden="true">
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' },
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Array.from({ length: 2 }, (_, i) => (
|
|
||||||
<Box
|
|
||||||
key={i}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 2,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
bgcolor: 'grey.50',
|
|
||||||
p: 1.5,
|
|
||||||
minHeight: 78,
|
|
||||||
display: 'grid',
|
|
||||||
gap: 1.25,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Skeleton height={12} width={92} shape="rounded" />
|
|
||||||
<Skeleton height={15} width={64} shape="rounded" />
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' },
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Array.from({ length: 6 }, (_, i) => (
|
|
||||||
<Box
|
|
||||||
key={i}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 2,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
bgcolor: 'grey.50',
|
|
||||||
p: 1.5,
|
|
||||||
minHeight: 72,
|
|
||||||
display: 'grid',
|
|
||||||
gap: 1.25,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Skeleton height={12} width={92} shape="rounded" />
|
|
||||||
<Skeleton height={15} width={64} shape="rounded" />
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
) : !data ? (
|
|
||||||
<Text tone="muted">Нет данных для аналитики</Text>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ display: 'grid', gap: 2 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' },
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
borderRadius: 2,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
bgcolor: 'rgba(255,255,255,0.5)',
|
|
||||||
p: 1.5,
|
|
||||||
minHeight: 72,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text variant="label" tone="secondary">
|
|
||||||
Стоимость портфеля
|
|
||||||
</Text>
|
|
||||||
<Box sx={{ mt: 0.5, fontWeight: 700, fontSize: 18, whiteSpace: 'nowrap' }}>
|
|
||||||
{formatDashboardCurrency({
|
|
||||||
currency: portfolioValue?.currency ?? data.currency,
|
|
||||||
value: portfolioValue?.value ?? 0,
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
borderRadius: 2,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
bgcolor: 'rgba(255,255,255,0.5)',
|
|
||||||
p: 1.5,
|
|
||||||
minHeight: 72,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text variant="label" tone="secondary">
|
|
||||||
Всего доходов
|
|
||||||
</Text>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
mt: 0.5,
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: 18,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
color: 'success.main',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatDashboardCurrency({
|
|
||||||
currency: data.currency,
|
|
||||||
value: data.totalDividends + data.totalCoupons,
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' },
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ANALYTICS_METRICS.map(({ field, label, testId }) => {
|
|
||||||
const value = data[field]
|
|
||||||
const tone = analyticsTone(field, value)
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={field}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 2,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor:
|
|
||||||
tone === 'positive'
|
|
||||||
? 'rgba(46, 125, 50, 0.3)'
|
|
||||||
: tone === 'negative'
|
|
||||||
? 'rgba(211, 47, 47, 0.3)'
|
|
||||||
: 'divider',
|
|
||||||
background:
|
|
||||||
tone === 'positive'
|
|
||||||
? 'linear-gradient(180deg, #f3faf5, #edf7f0)'
|
|
||||||
: tone === 'negative'
|
|
||||||
? 'linear-gradient(180deg, #fff7f5, #fff1ef)'
|
|
||||||
: undefined,
|
|
||||||
bgcolor: tone === 'positive' || tone === 'negative' ? undefined : 'grey.50',
|
|
||||||
p: 1.5,
|
|
||||||
minHeight: 72,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
color: 'text.secondary',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 700,
|
|
||||||
lineHeight: 1.25,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
data-testid={testId}
|
|
||||||
data-tone={tone}
|
|
||||||
sx={{
|
|
||||||
mt: 0.5,
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: 15,
|
|
||||||
lineHeight: 1.25,
|
|
||||||
color:
|
|
||||||
tone === 'positive'
|
|
||||||
? 'success.main'
|
|
||||||
: tone === 'negative'
|
|
||||||
? 'error.main'
|
|
||||||
: 'text.disabled',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{analyticsDisplay(field, value, data.currency)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</BrokerDashboardCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
import { Heading } from '@moex-vibe/design-system'
|
|
||||||
import { Box, type SxProps, type Theme } from '@mui/material'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
type BrokerDashboardCardProps = {
|
|
||||||
title: string
|
|
||||||
badge?: ReactNode
|
|
||||||
action?: ReactNode
|
|
||||||
filters?: ReactNode
|
|
||||||
children: ReactNode
|
|
||||||
sx?: SxProps<Theme>
|
|
||||||
ariaLabel?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BrokerDashboardCard({
|
|
||||||
title,
|
|
||||||
badge,
|
|
||||||
action,
|
|
||||||
filters,
|
|
||||||
children,
|
|
||||||
sx,
|
|
||||||
ariaLabel,
|
|
||||||
}: BrokerDashboardCardProps) {
|
|
||||||
const resolvedAriaLabel = ariaLabel ?? title
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
component="section"
|
|
||||||
aria-label={resolvedAriaLabel}
|
|
||||||
sx={{
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
borderRadius: 3,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
p: 2,
|
|
||||||
minWidth: 0,
|
|
||||||
boxShadow: '0 1px 3px rgba(15, 23, 42, 0.08)',
|
|
||||||
...sx,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 2,
|
|
||||||
mb: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
|
||||||
<Heading
|
|
||||||
level={2}
|
|
||||||
size="section"
|
|
||||||
style={{ fontSize: 22, fontWeight: 700, lineHeight: 1.18 }}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Heading>
|
|
||||||
{badge ? (
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
minHeight: 24,
|
|
||||||
px: 1,
|
|
||||||
borderRadius: 999,
|
|
||||||
bgcolor: 'grey.100',
|
|
||||||
color: 'text.secondary',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 700,
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{badge}
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
{action}
|
|
||||||
</Box>
|
|
||||||
{filters && <Box sx={{ mb: 1.5 }}>{filters}</Box>}
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,270 +0,0 @@
|
|||||||
import { Chip, Text } from '@moex-vibe/design-system'
|
|
||||||
import CalendarTodayRounded from '@mui/icons-material/CalendarTodayRounded'
|
|
||||||
import ExpandMoreRounded from '@mui/icons-material/ExpandMoreRounded'
|
|
||||||
import { Box, Popover } from '@mui/material'
|
|
||||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
|
|
||||||
import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'
|
|
||||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
|
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import { useCallback, useState } from 'react'
|
|
||||||
import type { DashboardDatePreset } from '../lib/dashboardFilters'
|
|
||||||
|
|
||||||
const PRESETS: { key: DashboardDatePreset; label: string }[] = [
|
|
||||||
{ key: '7d', label: '7д' },
|
|
||||||
{ key: '30d', label: '30д' },
|
|
||||||
{ key: '90d', label: '90д' },
|
|
||||||
{ key: '1y', label: '1г' },
|
|
||||||
{ key: 'all', label: 'Всё' },
|
|
||||||
]
|
|
||||||
|
|
||||||
type BrokerDashboardDateFilterProps = {
|
|
||||||
appliedLabel: string | null
|
|
||||||
preset: DashboardDatePreset
|
|
||||||
draftFrom: string
|
|
||||||
draftTo: string
|
|
||||||
hasDraftTypes: boolean
|
|
||||||
onPresetChange: (preset: DashboardDatePreset) => void
|
|
||||||
onFromChange: (value: string) => void
|
|
||||||
onToChange: (value: string) => void
|
|
||||||
onReset: () => void
|
|
||||||
onApply: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BrokerDashboardDateFilter({
|
|
||||||
appliedLabel,
|
|
||||||
preset,
|
|
||||||
draftFrom,
|
|
||||||
draftTo,
|
|
||||||
hasDraftTypes,
|
|
||||||
onPresetChange,
|
|
||||||
onFromChange,
|
|
||||||
onToChange,
|
|
||||||
onReset,
|
|
||||||
onApply,
|
|
||||||
}: BrokerDashboardDateFilterProps) {
|
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
|
||||||
const [selectingStage, setSelectingStage] = useState<'from' | 'to'>('from')
|
|
||||||
const isOpen = Boolean(anchorEl)
|
|
||||||
|
|
||||||
const handleToggle = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
|
||||||
setAnchorEl((prev) => {
|
|
||||||
if (prev) return null
|
|
||||||
return e.currentTarget
|
|
||||||
})
|
|
||||||
setSelectingStage('from')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
setAnchorEl(null)
|
|
||||||
setSelectingStage('from')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleCalendarChange = useCallback(
|
|
||||||
(date: dayjs.Dayjs | null) => {
|
|
||||||
if (!date) return
|
|
||||||
if (selectingStage === 'from') {
|
|
||||||
onFromChange(date.format('YYYY-MM-DD'))
|
|
||||||
setSelectingStage('to')
|
|
||||||
} else {
|
|
||||||
if (draftFrom && date.isBefore(dayjs(draftFrom))) {
|
|
||||||
onFromChange(date.format('YYYY-MM-DD'))
|
|
||||||
onToChange('')
|
|
||||||
} else {
|
|
||||||
onToChange(date.format('YYYY-MM-DD'))
|
|
||||||
}
|
|
||||||
setSelectingStage('from')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[selectingStage, draftFrom, onFromChange, onToChange],
|
|
||||||
)
|
|
||||||
|
|
||||||
function handlePresetClick(p: { key: DashboardDatePreset }) {
|
|
||||||
onPresetChange(p.key)
|
|
||||||
setSelectingStage('from')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleReset() {
|
|
||||||
onReset()
|
|
||||||
setSelectingStage('from')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleApply() {
|
|
||||||
onApply()
|
|
||||||
handleClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectingLabel =
|
|
||||||
selectingStage === 'from' ? 'Выберите начало периода' : 'Выберите конец периода'
|
|
||||||
const periodAriaLabel = appliedLabel ? `Период: ${appliedLabel}` : 'Период'
|
|
||||||
const rangeInverted = Boolean(draftFrom && draftTo) && dayjs(draftTo).isBefore(dayjs(draftFrom))
|
|
||||||
const canApply = hasDraftTypes && !rangeInverted
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
|
||||||
<Box
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
onClick={handleToggle}
|
|
||||||
aria-label={periodAriaLabel}
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
color: 'text.primary',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
borderRadius: 1,
|
|
||||||
px: 1.25,
|
|
||||||
py: 0.5,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: 'pointer',
|
|
||||||
'&:hover': { borderColor: 'primary.main' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
aria-hidden="true"
|
|
||||||
sx={{ display: 'inline-flex', color: 'text.secondary' }}
|
|
||||||
>
|
|
||||||
<CalendarTodayRounded sx={{ fontSize: 14 }} />
|
|
||||||
</Box>
|
|
||||||
{appliedLabel && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
color: 'text.secondary',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{appliedLabel}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
aria-hidden="true"
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
color: 'text.secondary',
|
|
||||||
transform: isOpen ? 'rotate(180deg)' : 'none',
|
|
||||||
transition: 'transform 0.15s ease-in-out',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ExpandMoreRounded sx={{ fontSize: 16 }} />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Popover
|
|
||||||
open={isOpen}
|
|
||||||
anchorEl={anchorEl}
|
|
||||||
onClose={handleClose}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
|
||||||
sx={{ mt: 0.5 }}
|
|
||||||
>
|
|
||||||
<Box sx={{ p: 2, minWidth: 320 }}>
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1 }}>
|
|
||||||
{PRESETS.map((p) => (
|
|
||||||
<Chip
|
|
||||||
key={p.key}
|
|
||||||
label={p.label}
|
|
||||||
tone={p.key === preset ? 'primary' : 'neutral'}
|
|
||||||
selected={p.key === preset}
|
|
||||||
onClick={() => handlePresetClick(p)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
<Text variant="caption" tone="secondary" sx={{ mb: 1, display: 'block' }}>
|
|
||||||
{selectingLabel}
|
|
||||||
</Text>
|
|
||||||
<DateCalendar
|
|
||||||
value={draftFrom ? dayjs(draftFrom) : null}
|
|
||||||
onChange={handleCalendarChange}
|
|
||||||
sx={{
|
|
||||||
'& .MuiPickersDay-root': {
|
|
||||||
...(draftFrom && draftTo
|
|
||||||
? {
|
|
||||||
'&.Mui-selected': {
|
|
||||||
bgcolor: 'primary.main',
|
|
||||||
color: 'common.white',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
onClick={handleReset}
|
|
||||||
sx={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: 'text.secondary',
|
|
||||||
fontSize: 12,
|
|
||||||
cursor: 'pointer',
|
|
||||||
textDecoration: 'underline',
|
|
||||||
textUnderlineOffset: 2,
|
|
||||||
p: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сбросить
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
onClick={handleApply}
|
|
||||||
disabled={!canApply}
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
|
||||||
bgcolor: canApply ? 'primary.main' : 'grey.400',
|
|
||||||
color: 'common.white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 1,
|
|
||||||
px: 1.5,
|
|
||||||
py: 0.75,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 700,
|
|
||||||
cursor: canApply ? 'pointer' : 'default',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Применить период
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Popover>
|
|
||||||
<Box
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
onClick={onApply}
|
|
||||||
disabled={!canApply}
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
|
||||||
bgcolor: canApply ? 'primary.main' : 'grey.400',
|
|
||||||
color: 'common.white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 1,
|
|
||||||
px: 1.5,
|
|
||||||
py: 0.5,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 700,
|
|
||||||
cursor: canApply ? 'pointer' : 'default',
|
|
||||||
'&:hover': canApply ? { bgcolor: 'primary.dark' } : {},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Обновить
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</LocalizationProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,288 +0,0 @@
|
|||||||
import { Text } from '@moex-vibe/design-system'
|
|
||||||
import { Box } from '@mui/material'
|
|
||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import type { BrokerOperation } from '@/shared/api'
|
|
||||||
import { formatBrokerDate } from '@/shared/lib/formatters'
|
|
||||||
import { formatDashboardCurrency, moneyToneToColor } from '../lib/dashboardVisual'
|
|
||||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
|
||||||
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
|
|
||||||
|
|
||||||
const TH_SX = {
|
|
||||||
textAlign: 'left' as const,
|
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
px: 1.25,
|
|
||||||
py: 1,
|
|
||||||
color: 'text.secondary',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 12,
|
|
||||||
textTransform: 'uppercase' as const,
|
|
||||||
whiteSpace: 'nowrap' as const,
|
|
||||||
}
|
|
||||||
|
|
||||||
const TD_SX_LEFT = {
|
|
||||||
px: 1.25,
|
|
||||||
py: 1,
|
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
verticalAlign: 'middle' as const,
|
|
||||||
}
|
|
||||||
|
|
||||||
const TD_SX_RIGHT = {
|
|
||||||
...TD_SX_LEFT,
|
|
||||||
textAlign: 'right' as const,
|
|
||||||
}
|
|
||||||
|
|
||||||
const TD_SX_INSTRUMENT = {
|
|
||||||
...TD_SX_LEFT,
|
|
||||||
minWidth: 180,
|
|
||||||
}
|
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 7
|
|
||||||
|
|
||||||
const BADGE_STYLES: Record<string, { bg: string; borderColor: string; color: string }> = {
|
|
||||||
coupon: { bg: '#e8f0ff', borderColor: '#cddcff', color: '#2563eb' },
|
|
||||||
dividend: { bg: '#e5f2ea', borderColor: '#bcdcc9', color: '#176747' },
|
|
||||||
maturity: { bg: '#fff5dc', borderColor: '#f0d898', color: '#8d6400' },
|
|
||||||
tax: { bg: '#fef0ef', borderColor: '#f6c6c2', color: '#b42318' },
|
|
||||||
fee: { bg: '#fef0ef', borderColor: '#f6c6c2', color: '#b42318' },
|
|
||||||
}
|
|
||||||
|
|
||||||
type BadgeInfo = { label: string; badgeKey: string }
|
|
||||||
|
|
||||||
type BrokerDashboardEventsCardProps = {
|
|
||||||
accountId: string
|
|
||||||
data: BrokerOperation[] | undefined
|
|
||||||
isLoading: boolean
|
|
||||||
isError: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOperationBadge(operation: BrokerOperation): BadgeInfo {
|
|
||||||
if (operation.category === 'tax') return { label: 'Налог', badgeKey: 'tax' }
|
|
||||||
if (operation.category === 'fee') return { label: 'Комиссия', badgeKey: 'fee' }
|
|
||||||
if (operation.category === 'income') {
|
|
||||||
if (
|
|
||||||
operation.type === 'OPERATION_TYPE_DIVIDEND' ||
|
|
||||||
operation.type === 'OPERATION_TYPE_DIV_EXT'
|
|
||||||
) {
|
|
||||||
return { label: 'Дивиденд', badgeKey: 'dividend' }
|
|
||||||
}
|
|
||||||
if (operation.type === 'OPERATION_TYPE_COUPON') {
|
|
||||||
return { label: 'Купон', badgeKey: 'coupon' }
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
operation.type === 'OPERATION_TYPE_BOND_REPAYMENT' ||
|
|
||||||
operation.type === 'OPERATION_TYPE_BOND_REPAYMENT_FULL' ||
|
|
||||||
operation.type === 'OPERATION_TYPE_MATURITY'
|
|
||||||
) {
|
|
||||||
return { label: 'Погашение', badgeKey: 'maturity' }
|
|
||||||
}
|
|
||||||
return { label: 'Доход', badgeKey: '' }
|
|
||||||
}
|
|
||||||
return { label: 'Прочее', badgeKey: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function Badge({ label, badgeKey }: { label: string; badgeKey: string }) {
|
|
||||||
const style = BADGE_STYLES[badgeKey]
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
minHeight: 24,
|
|
||||||
px: 1.25,
|
|
||||||
borderRadius: '999px',
|
|
||||||
border: 1,
|
|
||||||
borderColor: style?.borderColor ?? 'divider',
|
|
||||||
bgcolor: style?.bg ?? 'transparent',
|
|
||||||
color: style?.color ?? 'text.secondary',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 700,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PagerButton({
|
|
||||||
disabled,
|
|
||||||
label,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
disabled?: boolean
|
|
||||||
label: string
|
|
||||||
onClick?: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onClick}
|
|
||||||
sx={{
|
|
||||||
minWidth: 34,
|
|
||||||
height: 32,
|
|
||||||
border: 1,
|
|
||||||
borderColor: disabled ? 'divider' : 'grey.300',
|
|
||||||
borderRadius: 1,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
color: disabled ? 'text.disabled' : 'primary.main',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
cursor: disabled ? 'default' : 'pointer',
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
lineHeight: 1,
|
|
||||||
px: 1,
|
|
||||||
'&:hover:not(:disabled)': { bgcolor: 'grey.100' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BrokerDashboardEventsCard({
|
|
||||||
accountId,
|
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
}: BrokerDashboardEventsCardProps) {
|
|
||||||
const [page, setPage] = useState(0)
|
|
||||||
const operations = data ?? []
|
|
||||||
const totalPages = Math.max(1, Math.ceil(operations.length / ITEMS_PER_PAGE))
|
|
||||||
const safePage = Math.min(page, totalPages - 1)
|
|
||||||
const pageStart = safePage * ITEMS_PER_PAGE
|
|
||||||
const pageEnd = pageStart + ITEMS_PER_PAGE
|
|
||||||
const pageItems = operations.slice(pageStart, pageEnd)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BrokerDashboardCard
|
|
||||||
title="Последние события"
|
|
||||||
action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>}
|
|
||||||
>
|
|
||||||
{isError ? (
|
|
||||||
<Text tone="negative">Не удалось загрузить события</Text>
|
|
||||||
) : isLoading ? (
|
|
||||||
<BrokerDashboardTableSkeleton rows={5} columns={4} />
|
|
||||||
) : operations.length === 0 ? (
|
|
||||||
<Text tone="muted">Событий нет</Text>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ overflowX: 'auto' }}>
|
|
||||||
<Box
|
|
||||||
component="table"
|
|
||||||
aria-label="Таблица последних событий брокерского счёта"
|
|
||||||
sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, minWidth: 520 }}
|
|
||||||
>
|
|
||||||
<Box component="thead">
|
|
||||||
<Box component="tr">
|
|
||||||
<Box component="th" sx={TH_SX}>
|
|
||||||
Дата
|
|
||||||
</Box>
|
|
||||||
<Box component="th" sx={TH_SX}>
|
|
||||||
Инструмент
|
|
||||||
</Box>
|
|
||||||
<Box component="th" sx={TH_SX}>
|
|
||||||
Тип
|
|
||||||
</Box>
|
|
||||||
<Box component="th" sx={{ ...TH_SX, textAlign: 'right' }}>
|
|
||||||
Сумма
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box component="tbody">
|
|
||||||
{pageItems.map((op) => {
|
|
||||||
const boldText = op.name ?? op.description ?? op.ticker ?? '—'
|
|
||||||
const grayText = op.name ? (op.ticker ?? null) : null
|
|
||||||
const { label, badgeKey } = getOperationBadge(op)
|
|
||||||
const amountValue = op.payment?.value ?? 0
|
|
||||||
const amountTone = amountValue >= 0 ? 'positive' : 'negative'
|
|
||||||
const formattedAmount = `${amountValue >= 0 ? '+' : '\u2212'}${formatDashboardCurrency(
|
|
||||||
op.payment
|
|
||||||
? { currency: op.payment.currency, value: Math.abs(amountValue) }
|
|
||||||
: { currency: 'RUB', value: 0 },
|
|
||||||
)}`
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
component="tr"
|
|
||||||
key={String(op.id ?? op.cursor ?? `${op.type}-${op.date}`)}
|
|
||||||
data-testid="dashboard-events-row"
|
|
||||||
>
|
|
||||||
<Box component="td" sx={TD_SX_LEFT}>
|
|
||||||
{formatBrokerDate(typeof op.date === 'string' ? op.date : null) ?? '—'}
|
|
||||||
</Box>
|
|
||||||
<Box component="td" sx={TD_SX_INSTRUMENT}>
|
|
||||||
<Box sx={{ display: 'grid', gap: 0.25 }}>
|
|
||||||
<Box
|
|
||||||
sx={{ fontWeight: 700 }}
|
|
||||||
data-testid="dashboard-events-instrument-main"
|
|
||||||
>
|
|
||||||
{boldText}
|
|
||||||
</Box>
|
|
||||||
{grayText ? (
|
|
||||||
<Box
|
|
||||||
sx={{ fontSize: 12, color: 'text.secondary' }}
|
|
||||||
data-testid="dashboard-events-instrument-subtitle"
|
|
||||||
>
|
|
||||||
{grayText}
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box component="td" sx={TD_SX_LEFT}>
|
|
||||||
<Badge label={label} badgeKey={badgeKey} />
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
component="td"
|
|
||||||
sx={{
|
|
||||||
...TD_SX_RIGHT,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: moneyToneToColor(amountTone),
|
|
||||||
}}
|
|
||||||
data-testid="dashboard-events-amount"
|
|
||||||
data-tone={amountTone}
|
|
||||||
>
|
|
||||||
{formattedAmount}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1.5,
|
|
||||||
mt: 1.5,
|
|
||||||
minHeight: 34,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<PagerButton
|
|
||||||
disabled={safePage === 0}
|
|
||||||
label="←"
|
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
||||||
/>
|
|
||||||
<Box component="span" sx={{ fontSize: 13, color: 'text.secondary' }}>
|
|
||||||
{safePage + 1}
|
|
||||||
</Box>
|
|
||||||
<PagerButton
|
|
||||||
disabled={safePage >= totalPages - 1}
|
|
||||||
label="→"
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</BrokerDashboardCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
import { Metric, Text } from '@moex-vibe/design-system'
|
|
||||||
import { Box } from '@mui/material'
|
|
||||||
import type { BrokerAnalytics, BrokerPortfolio } from '@/shared/api'
|
|
||||||
import { formatBrokerMoney, formatBrokerPercent } from '@/shared/lib/formatters'
|
|
||||||
import { formatDashboardCurrency, moneyTone, moneyToneToColor } from '../lib/dashboardVisual'
|
|
||||||
|
|
||||||
type BrokerDashboardHeroProps = {
|
|
||||||
portfolio: BrokerPortfolio
|
|
||||||
analytics: BrokerAnalytics | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function percentValue(value: unknown): string {
|
|
||||||
return typeof value === 'number' ? formatBrokerPercent(value) : '—'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHeroProps) {
|
|
||||||
const accountName = portfolio.account.name || 'Брокерский счёт'
|
|
||||||
const returnPercent = analytics?.totalReturnPercent ?? portfolio.yields.expectedPercent
|
|
||||||
const returnTone = moneyTone(typeof returnPercent === 'number' ? returnPercent : null)
|
|
||||||
const dailyTone = moneyTone(portfolio.yields.daily?.value ?? null)
|
|
||||||
const totalReceivedTone = moneyTone(analytics?.totalReceived ?? null)
|
|
||||||
const totalReceivedDisplay = analytics
|
|
||||||
? formatDashboardCurrency({ currency: analytics.currency, value: analytics.totalReceived })
|
|
||||||
: '—'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
component="section"
|
|
||||||
aria-label="Ключевые показатели брокерского счёта"
|
|
||||||
sx={{
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'success.light',
|
|
||||||
borderRadius: 4,
|
|
||||||
bgcolor: 'rgba(46, 125, 50, 0.06)',
|
|
||||||
p: { xs: 2, md: 3 },
|
|
||||||
display: 'grid',
|
|
||||||
gap: 2,
|
|
||||||
gridTemplateColumns: { xs: '1fr', md: 'minmax(240px, 1fr) repeat(3, auto)' },
|
|
||||||
alignItems: 'stretch',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Text variant="label" tone="secondary">
|
|
||||||
Инвестиционный дашборд
|
|
||||||
</Text>
|
|
||||||
<Box sx={{ fontWeight: 800, fontSize: { xs: 22, md: 28 }, lineHeight: 1.15 }}>
|
|
||||||
{accountName}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
|
||||||
<Metric label="Стоимость портфеля" value={formatBrokerMoney(portfolio.totals.portfolio)} />
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
|
||||||
<Metric
|
|
||||||
label="Доходность"
|
|
||||||
value={
|
|
||||||
<Box component="span" sx={{ color: moneyToneToColor(returnTone), fontWeight: 700 }}>
|
|
||||||
{percentValue(returnPercent)}
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
supportingText={
|
|
||||||
<Box component="span" sx={{ color: moneyToneToColor(dailyTone) }}>
|
|
||||||
За день: {formatBrokerMoney(portfolio.yields.daily)}
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
|
||||||
<Metric
|
|
||||||
label="Всего доходов"
|
|
||||||
value={
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
sx={{ color: moneyToneToColor(totalReceivedTone), fontWeight: 700 }}
|
|
||||||
>
|
|
||||||
{totalReceivedDisplay}
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,273 +0,0 @@
|
|||||||
import { Button, Chip, Text } from '@moex-vibe/design-system'
|
|
||||||
import { Box } from '@mui/material'
|
|
||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import type { BrokerOperationsPage } from '@/shared/api'
|
|
||||||
import { formatBrokerDate } from '@/shared/lib/formatters'
|
|
||||||
import type { DashboardDatePreset, DashboardIncomeType } from '../lib/dashboardFilters'
|
|
||||||
import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome'
|
|
||||||
import {
|
|
||||||
formatDashboardCurrency,
|
|
||||||
incomeTypeTone,
|
|
||||||
moneyToneToColor,
|
|
||||||
type TypeTone,
|
|
||||||
} from '../lib/dashboardVisual'
|
|
||||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
|
||||||
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
|
|
||||||
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
|
|
||||||
import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar'
|
|
||||||
|
|
||||||
const INCOME_FILTERS: Array<{
|
|
||||||
type: DashboardIncomeType
|
|
||||||
label: string
|
|
||||||
tone: TypeTone
|
|
||||||
}> = [
|
|
||||||
{ type: 'dividend', label: 'Дивиденды', tone: 'success' },
|
|
||||||
{ type: 'coupon', label: 'Купоны', tone: 'info' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const TH_SX = {
|
|
||||||
textAlign: 'left' as const,
|
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
px: 1.25,
|
|
||||||
py: 1,
|
|
||||||
color: 'text.secondary',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 12,
|
|
||||||
textTransform: 'uppercase' as const,
|
|
||||||
whiteSpace: 'nowrap' as const,
|
|
||||||
}
|
|
||||||
|
|
||||||
const TD_SX_LEFT = {
|
|
||||||
px: 1.25,
|
|
||||||
py: 1,
|
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
verticalAlign: 'middle' as const,
|
|
||||||
}
|
|
||||||
|
|
||||||
const TD_SX_RIGHT = {
|
|
||||||
...TD_SX_LEFT,
|
|
||||||
textAlign: 'right' as const,
|
|
||||||
}
|
|
||||||
|
|
||||||
const TD_SX_INSTRUMENT = {
|
|
||||||
...TD_SX_LEFT,
|
|
||||||
minWidth: 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
type BrokerDashboardIncomeCardProps = {
|
|
||||||
accountId: string
|
|
||||||
page: BrokerOperationsPage | undefined
|
|
||||||
isLoading: boolean
|
|
||||||
isError: boolean
|
|
||||||
selectedTypes: DashboardIncomeType[]
|
|
||||||
onToggleType: (type: DashboardIncomeType) => void
|
|
||||||
appliedDateLabel: string | null
|
|
||||||
draftPreset: DashboardDatePreset
|
|
||||||
draftFrom: string
|
|
||||||
draftTo: string
|
|
||||||
onDraftPresetChange: (preset: DashboardDatePreset) => void
|
|
||||||
onDraftFromChange: (value: string) => void
|
|
||||||
onDraftToChange: (value: string) => void
|
|
||||||
onApplyFilters: () => void
|
|
||||||
onResetFilters: () => void
|
|
||||||
hasDraftTypes: boolean
|
|
||||||
visibleCount: number
|
|
||||||
pageNumber: number
|
|
||||||
canGoBack: boolean
|
|
||||||
canGoForward: boolean
|
|
||||||
onPreviousPage: () => void
|
|
||||||
onNextPage: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BrokerDashboardIncomeCard({
|
|
||||||
accountId,
|
|
||||||
page,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
selectedTypes,
|
|
||||||
onToggleType,
|
|
||||||
appliedDateLabel,
|
|
||||||
draftPreset,
|
|
||||||
draftFrom,
|
|
||||||
draftTo,
|
|
||||||
onDraftPresetChange,
|
|
||||||
onDraftFromChange,
|
|
||||||
onDraftToChange,
|
|
||||||
onApplyFilters,
|
|
||||||
onResetFilters,
|
|
||||||
hasDraftTypes,
|
|
||||||
visibleCount,
|
|
||||||
pageNumber,
|
|
||||||
canGoBack,
|
|
||||||
canGoForward,
|
|
||||||
onPreviousPage,
|
|
||||||
onNextPage,
|
|
||||||
}: BrokerDashboardIncomeCardProps) {
|
|
||||||
const rows = getDashboardIncomeRows(page?.items ?? []).slice(0, 10)
|
|
||||||
const total = sumDashboardIncome(rows)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BrokerDashboardCard
|
|
||||||
title="Доходы"
|
|
||||||
badge={rows.length > 0 ? `${visibleCount} операции` : undefined}
|
|
||||||
action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>}
|
|
||||||
filters={
|
|
||||||
<BrokerDashboardTableToolbar
|
|
||||||
chips={INCOME_FILTERS.map((filter) => (
|
|
||||||
<Chip
|
|
||||||
key={filter.type}
|
|
||||||
label={filter.label}
|
|
||||||
tone={filter.tone}
|
|
||||||
selected={selectedTypes.includes(filter.type)}
|
|
||||||
onClick={() => onToggleType(filter.type)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
>
|
|
||||||
<BrokerDashboardDateFilter
|
|
||||||
appliedLabel={appliedDateLabel}
|
|
||||||
preset={draftPreset}
|
|
||||||
draftFrom={draftFrom}
|
|
||||||
draftTo={draftTo}
|
|
||||||
hasDraftTypes={hasDraftTypes}
|
|
||||||
onPresetChange={onDraftPresetChange}
|
|
||||||
onFromChange={onDraftFromChange}
|
|
||||||
onToChange={onDraftToChange}
|
|
||||||
onReset={onResetFilters}
|
|
||||||
onApply={onApplyFilters}
|
|
||||||
/>
|
|
||||||
</BrokerDashboardTableToolbar>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{selectedTypes.length === 0 ? (
|
|
||||||
<Text tone="negative">Выберите хотя бы один тип доходов</Text>
|
|
||||||
) : isError ? (
|
|
||||||
<Text tone="negative">Не удалось загрузить доходные операции</Text>
|
|
||||||
) : isLoading ? (
|
|
||||||
<BrokerDashboardTableSkeleton rows={5} columns={4} />
|
|
||||||
) : rows.length === 0 ? (
|
|
||||||
<Text tone="muted">Дивидендов и купонов в последних операциях нет</Text>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
|
||||||
<Box sx={{ overflowX: 'auto' }}>
|
|
||||||
<Box
|
|
||||||
component="table"
|
|
||||||
aria-label="Таблица доходов брокерского счёта"
|
|
||||||
sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, minWidth: 520 }}
|
|
||||||
>
|
|
||||||
<Box component="thead">
|
|
||||||
<Box component="tr">
|
|
||||||
<Box component="th" sx={TH_SX}>
|
|
||||||
Дата
|
|
||||||
</Box>
|
|
||||||
<Box component="th" sx={TH_SX}>
|
|
||||||
Инструмент
|
|
||||||
</Box>
|
|
||||||
<Box component="th" sx={TH_SX}>
|
|
||||||
Тип
|
|
||||||
</Box>
|
|
||||||
<Box component="th" sx={{ ...TH_SX, textAlign: 'right' }}>
|
|
||||||
Сумма
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box component="tbody">
|
|
||||||
{rows.map((row) => {
|
|
||||||
const formattedAmount = `${row.amount.value >= 0 ? '+' : '−'}${formatDashboardCurrency(
|
|
||||||
{ currency: row.amount.currency, value: Math.abs(row.amount.value) },
|
|
||||||
)}`
|
|
||||||
const amountTone = row.amount.value >= 0 ? 'positive' : 'negative'
|
|
||||||
return (
|
|
||||||
<Box component="tr" key={row.id} data-testid="dashboard-income-row">
|
|
||||||
<Box component="td" sx={TD_SX_LEFT}>
|
|
||||||
{formatBrokerDate(row.date) ?? '—'}
|
|
||||||
</Box>
|
|
||||||
<Box component="td" sx={TD_SX_INSTRUMENT}>
|
|
||||||
<Box sx={{ display: 'grid', gap: 0.25 }}>
|
|
||||||
<Box
|
|
||||||
sx={{ fontWeight: 700 }}
|
|
||||||
data-testid="dashboard-income-instrument-main"
|
|
||||||
>
|
|
||||||
{row.instrumentMain}
|
|
||||||
</Box>
|
|
||||||
{row.instrumentSubtitle ? (
|
|
||||||
<Box
|
|
||||||
sx={{ fontSize: 12, color: 'text.secondary' }}
|
|
||||||
data-testid="dashboard-income-instrument-subtitle"
|
|
||||||
>
|
|
||||||
{row.instrumentSubtitle}
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box component="td" sx={TD_SX_LEFT}>
|
|
||||||
<Chip
|
|
||||||
label={row.typeLabel}
|
|
||||||
tone={incomeTypeTone(row.typeLabel)}
|
|
||||||
selected={false}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
component="td"
|
|
||||||
sx={{
|
|
||||||
...TD_SX_RIGHT,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: moneyToneToColor(amountTone),
|
|
||||||
}}
|
|
||||||
data-testid="dashboard-income-amount"
|
|
||||||
data-tone={amountTone}
|
|
||||||
>
|
|
||||||
{formattedAmount}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text variant="caption" tone="secondary">
|
|
||||||
Показано {rows.length} · Итого:{' '}
|
|
||||||
{total
|
|
||||||
? `${total.value >= 0 ? '+' : '−'}${formatDashboardCurrency({
|
|
||||||
currency: total.currency,
|
|
||||||
value: Math.abs(total.value),
|
|
||||||
})}`
|
|
||||||
: '—'}
|
|
||||||
</Text>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, alignItems: 'center' }}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
onClick={onPreviousPage}
|
|
||||||
disabled={!canGoBack}
|
|
||||||
>
|
|
||||||
←
|
|
||||||
</Button>
|
|
||||||
<Text variant="body" tone="secondary">
|
|
||||||
{pageNumber}
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
onClick={onNextPage}
|
|
||||||
disabled={!canGoForward}
|
|
||||||
>
|
|
||||||
→
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</BrokerDashboardCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
import { Skeleton } from '@moex-vibe/design-system'
|
|
||||||
import { Box } from '@mui/material'
|
|
||||||
|
|
||||||
export function BrokerDashboardSkeleton() {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'grid', gap: 3 }} aria-label="Загрузка брокерского дашборда">
|
|
||||||
<Skeleton height={120} shape="rounded" />
|
|
||||||
<Skeleton height={300} shape="rounded" />
|
|
||||||
<Skeleton height={300} shape="rounded" />
|
|
||||||
<Skeleton height={260} shape="rounded" />
|
|
||||||
<Skeleton height={260} shape="rounded" />
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
import { Skeleton } from '@moex-vibe/design-system'
|
|
||||||
import { Box } from '@mui/material'
|
|
||||||
|
|
||||||
type ColumnWidth = string
|
|
||||||
|
|
||||||
type BrokerDashboardTableSkeletonProps = {
|
|
||||||
rows?: number
|
|
||||||
columns?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_EVENT_WIDTHS: ColumnWidth[] = ['14%', '32%', '18%', '18%', '18%']
|
|
||||||
const DEFAULT_INCOME_WIDTHS: ColumnWidth[] = ['18%', '40%', '22%', '20%']
|
|
||||||
|
|
||||||
const EVENT_ROW_VARIANTS: Array<'short' | 'long' | 'full'> = [
|
|
||||||
'short',
|
|
||||||
'long',
|
|
||||||
'medium',
|
|
||||||
'short',
|
|
||||||
'medium',
|
|
||||||
]
|
|
||||||
const INCOME_ROW_VARIANTS: Array<'short' | 'long' | 'full'> = ['short', 'long', 'medium', 'short']
|
|
||||||
|
|
||||||
function widthsFor(columns: number): ColumnWidth[] {
|
|
||||||
if (columns === 4) return DEFAULT_INCOME_WIDTHS
|
|
||||||
if (columns === 5) return DEFAULT_EVENT_WIDTHS
|
|
||||||
return Array.from({ length: columns }, () => '100%')
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowVariantsFor(columns: number): Array<'short' | 'long' | 'full' | 'medium'> {
|
|
||||||
if (columns === 4) return INCOME_ROW_VARIANTS
|
|
||||||
return EVENT_ROW_VARIANTS.slice(0, columns)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BrokerDashboardTableSkeleton({
|
|
||||||
rows = 5,
|
|
||||||
columns = 5,
|
|
||||||
}: BrokerDashboardTableSkeletonProps) {
|
|
||||||
const widths = widthsFor(columns)
|
|
||||||
const variants = rowVariantsFor(columns)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{ display: 'grid', gap: 1, py: 1 }}
|
|
||||||
data-testid="dashboard-table-skeleton"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{Array.from({ length: rows }, (_, i) => (
|
|
||||||
<Box
|
|
||||||
key={i}
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 2,
|
|
||||||
alignItems: 'center',
|
|
||||||
minHeight: 38,
|
|
||||||
py: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{widths.map((width, j) => (
|
|
||||||
<Box key={j} sx={{ flex: `0 0 ${width}`, minWidth: 0 }}>
|
|
||||||
<Skeleton
|
|
||||||
height={14}
|
|
||||||
width={
|
|
||||||
variants[j] === 'short'
|
|
||||||
? '45%'
|
|
||||||
: variants[j] === 'medium'
|
|
||||||
? '65%'
|
|
||||||
: variants[j] === 'full'
|
|
||||||
? '100%'
|
|
||||||
: '74%'
|
|
||||||
}
|
|
||||||
shape="rounded"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import { Box } from '@mui/material'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
type BrokerDashboardTableToolbarProps = {
|
|
||||||
chips: ReactNode
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BrokerDashboardTableToolbar({ chips, children }: BrokerDashboardTableToolbarProps) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: { xs: '1fr', md: 'minmax(0, 1fr) auto auto' },
|
|
||||||
gap: 1.25,
|
|
||||||
alignItems: 'center',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
bgcolor: 'grey.50',
|
|
||||||
borderRadius: 2,
|
|
||||||
p: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'grid', gap: 0.75, minWidth: 0 }}>
|
|
||||||
<Box sx={{ color: 'text.secondary', fontSize: 12, fontWeight: 700, lineHeight: 1 }}>
|
|
||||||
Тип
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, minWidth: 0 }}>{chips}</Box>
|
|
||||||
</Box>
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,208 +0,0 @@
|
|||||||
import { Skeleton, Text } from '@moex-vibe/design-system'
|
|
||||||
import { Box } from '@mui/material'
|
|
||||||
import type { BrokerMoney, BrokerPortfolioHistoryData } from '@/shared/api'
|
|
||||||
import { formatDashboardCurrency } from '../lib/dashboardVisual'
|
|
||||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
|
||||||
|
|
||||||
type BrokerPortfolioHistoryCardProps = {
|
|
||||||
data: BrokerPortfolioHistoryData | undefined
|
|
||||||
portfolioValue: BrokerMoney | undefined
|
|
||||||
isLoading: boolean
|
|
||||||
isError: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateChartPath(points: { value: BrokerMoney }[]): { line: string; area: string } {
|
|
||||||
const values = points.map((p) => p.value.value)
|
|
||||||
const min = Math.min(...values)
|
|
||||||
const max = Math.max(...values)
|
|
||||||
const range = max - min || 1
|
|
||||||
const padding = range * 0.1
|
|
||||||
|
|
||||||
const viewWidth = 300
|
|
||||||
const viewHeight = 90
|
|
||||||
const padLeft = 10
|
|
||||||
const padRight = 10
|
|
||||||
const padTop = 5
|
|
||||||
const padBottom = 5
|
|
||||||
const plotWidth = viewWidth - padLeft - padRight
|
|
||||||
const plotHeight = viewHeight - padTop - padBottom
|
|
||||||
const yMin = min - padding
|
|
||||||
const yRange = max + padding - yMin
|
|
||||||
|
|
||||||
function x(i: number) {
|
|
||||||
return padLeft + (i / (points.length - 1)) * plotWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
function y(val: number) {
|
|
||||||
return padTop + plotHeight - ((val - yMin) / yRange) * plotHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
let lineCmd = `M ${x(0)},${y(values[0])}`
|
|
||||||
for (let i = 1; i < points.length; i++) {
|
|
||||||
const x0 = x(i - 1)
|
|
||||||
const y0 = y(values[i - 1])
|
|
||||||
const x1 = x(i)
|
|
||||||
const y1 = y(values[i])
|
|
||||||
const cx1 = x0 + (x1 - x0) / 2
|
|
||||||
const cx2 = x0 + (x1 - x0) / 2
|
|
||||||
lineCmd += ` C ${cx1},${y0} ${cx2},${y1} ${x1},${y1}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const bottom = viewHeight
|
|
||||||
const areaCmd = `${lineCmd} L ${x(points.length - 1)},${bottom} L ${x(0)},${bottom} Z`
|
|
||||||
|
|
||||||
return { line: lineCmd, area: areaCmd }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BrokerPortfolioHistoryCard({
|
|
||||||
data,
|
|
||||||
portfolioValue,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
}: BrokerPortfolioHistoryCardProps) {
|
|
||||||
return (
|
|
||||||
<BrokerDashboardCard
|
|
||||||
title="Стоимость портфеля за 6 месяцев"
|
|
||||||
sx={{
|
|
||||||
borderColor: 'success.light',
|
|
||||||
background: 'linear-gradient(135deg, rgba(229, 242, 234, 0.96), rgba(255, 255, 255, 0.86))',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
if (isError) {
|
|
||||||
return <Text tone="negative">Не удалось загрузить историю портфеля</Text>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading || !data) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'grid', gap: 2.5, minHeight: 180 }} aria-hidden="true">
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
|
||||||
<Skeleton height={12} width={120} shape="rounded" />
|
|
||||||
<Skeleton height={18} width={140} shape="rounded" />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
height: 120,
|
|
||||||
mx: -2.25,
|
|
||||||
width: 'calc(100% + 36px)',
|
|
||||||
borderRadius: 2,
|
|
||||||
background: 'rgba(46, 125, 50, 0.05)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 20,
|
|
||||||
borderTop: '1px dashed rgba(46, 125, 50, 0.16)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Skeleton height="100%" width="100%" shape="rectangular" />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const points = data.points
|
|
||||||
if (points.length === 0) {
|
|
||||||
return <Text tone="muted">Нет данных за выбранный период</Text>
|
|
||||||
}
|
|
||||||
|
|
||||||
const { line, area } = generateChartPath(points)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'grid', gap: 0.25 }}>
|
|
||||||
<Text variant="label" tone="secondary">
|
|
||||||
Текущая стоимость
|
|
||||||
</Text>
|
|
||||||
<Box sx={{ fontWeight: 700, fontSize: 18, lineHeight: 1.25 }}>
|
|
||||||
{portfolioValue ? formatDashboardCurrency(portfolioValue) : '—'}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ mx: -2.25, width: 'calc(100% + 36px)' }}>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 300 90"
|
|
||||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
|
||||||
aria-label="График изменения стоимости портфеля"
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="areaGradient" x1="0" x2="0" y1="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#2e7d32" stopOpacity={0.2} />
|
|
||||||
<stop offset="100%" stopColor="#2e7d32" stopOpacity={0.02} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<path d={area} fill="url(#areaGradient)" />
|
|
||||||
<path
|
|
||||||
d={line}
|
|
||||||
fill="none"
|
|
||||||
stroke="#2e7d32"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
mx: -2.25,
|
|
||||||
width: 'calc(100% + 36px)',
|
|
||||||
height: 20,
|
|
||||||
color: 'text.disabled',
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{points.map((point, i) => {
|
|
||||||
const plotWidth = 300 - 10 - 10
|
|
||||||
const leftPct = ((10 + (i / (points.length - 1)) * plotWidth) / 300) * 100
|
|
||||||
const isFirst = i === 0
|
|
||||||
const isLast = i === points.length - 1
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={point.month}
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 0,
|
|
||||||
left: isLast ? `${leftPct}%` : `${leftPct}%`,
|
|
||||||
transform: isFirst
|
|
||||||
? 'none'
|
|
||||||
: isLast
|
|
||||||
? 'translateX(-100%)'
|
|
||||||
: 'translateX(-50%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{point.label}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</BrokerDashboardCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
# Backend Architecture
|
|
||||||
|
|
||||||
Статус: активный
|
|
||||||
|
|
||||||
Цель: поддерживать backend в состоянии, где архитектурные границы, контракты, безопасность и
|
|
||||||
наблюдаемость позволяют развивать продукт без скрытого роста технического долга.
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- [x] [backend-architecture-improvements](../features/backend-architecture-improvements/spec.md) —
|
|
||||||
закрытие первой волны backend-аудита: envelope DTO, screener TTL, domain exceptions, health checks,
|
|
||||||
middleware DI, MoexClient split.
|
|
||||||
- [ ] [backend-architecture-refactor](../features/backend-architecture-refactor/spec.md) —
|
|
||||||
refactor-only итерация без новых user-facing возможностей.
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
# Architecture Quality Backlog Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** publish a canonical architecture backlog in `docs/` and reconcile roadmap/audit statuses without changing runtime code.
|
|
||||||
|
|
||||||
**Architecture:** this feature is a documentation workflow. We inventory the already completed architecture work, collapse stale and duplicated backlog entries, and publish one normalized backlog that future features can implement independently.
|
|
||||||
|
|
||||||
**Tech Stack:** Markdown docs in `docs/`, Docusaurus-published architecture pages in `apps/docs/docs/`, repository search and patch tooling.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Promote canonical feature docs
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `docs/features/architecture-quality-backlog/spec.md`
|
|
||||||
- Create: `docs/features/architecture-quality-backlog/plan.md`
|
|
||||||
- Create: `docs/features/architecture-quality-backlog/tasks.md`
|
|
||||||
|
|
||||||
- [x] **Step 1: Move the worktree draft into canonical docs**
|
|
||||||
|
|
||||||
Create the feature directory in the main workspace and copy the prepared `spec/plan/tasks` structure.
|
|
||||||
|
|
||||||
- [x] **Step 2: Rewrite the feature around the actual output**
|
|
||||||
|
|
||||||
Describe the feature as completed docs-only normalization work rather than an unexecuted draft.
|
|
||||||
|
|
||||||
- [x] **Step 3: Publish the normalized backlog**
|
|
||||||
|
|
||||||
Keep one ordered backlog with explicit risks, dependencies, and future feature boundaries.
|
|
||||||
|
|
||||||
### Task 2: Reconcile related source-of-truth docs
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `docs/features/frontend-debt-audit/spec.md`
|
|
||||||
- Modify: `docs/features/backend-architecture-refactor/spec.md`
|
|
||||||
|
|
||||||
- [x] **Step 1: Remove stale already-closed debt notes**
|
|
||||||
|
|
||||||
Reclassify items already covered by `api-envelope-contract` and `backend-architecture-refactor`.
|
|
||||||
|
|
||||||
- [x] **Step 2: Point remaining open debt at the canonical backlog**
|
|
||||||
|
|
||||||
Avoid competing backlog lists by linking residual items to `architecture-quality-backlog`.
|
|
||||||
|
|
||||||
- [x] **Step 3: Synchronize status wording**
|
|
||||||
|
|
||||||
Update `backend-architecture-refactor` from draft to completed state to match its finished tasks.
|
|
||||||
|
|
||||||
### Task 3: Publish roadmap changes
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `docs/roadmap.md`
|
|
||||||
|
|
||||||
- [x] **Step 1: Mark completed architecture refactor work as done**
|
|
||||||
|
|
||||||
`Backend Architecture Refactor` must no longer appear as open if the feature tasks and verification log are complete.
|
|
||||||
|
|
||||||
- [x] **Step 2: Add the new completed docs-only feature**
|
|
||||||
|
|
||||||
Record `architecture-quality-backlog` in the completed work history as the canonical backlog publication step.
|
|
||||||
|
|
||||||
- [x] **Step 3: Replace the loose candidate list with normalized tracks**
|
|
||||||
|
|
||||||
Publish the same ordered backlog from the feature spec so roadmap and feature docs match.
|
|
||||||
|
|
||||||
### Task 4: Final consistency pass
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Read: `docs/features/architecture-quality-backlog/spec.md`
|
|
||||||
- Read: `docs/features/architecture-quality-backlog/plan.md`
|
|
||||||
- Read: `docs/features/architecture-quality-backlog/tasks.md`
|
|
||||||
- Read: `docs/features/frontend-debt-audit/spec.md`
|
|
||||||
- Read: `docs/features/backend-architecture-refactor/spec.md`
|
|
||||||
- Read: `docs/roadmap.md`
|
|
||||||
|
|
||||||
- [x] **Step 1: Confirm docs-only scope**
|
|
||||||
|
|
||||||
Only documentation files should change.
|
|
||||||
|
|
||||||
- [x] **Step 2: Confirm consistent backlog ordering**
|
|
||||||
|
|
||||||
The five normalized directions must appear with the same meaning everywhere they are referenced.
|
|
||||||
|
|
||||||
- [x] **Step 3: Confirm no duplicate stale backlog remains**
|
|
||||||
|
|
||||||
Historical docs can keep audit context, but they should no longer compete with the canonical backlog.
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
# Architecture Quality Backlog
|
|
||||||
|
|
||||||
Дата: 2026-06-26
|
|
||||||
Статус: выполнено
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
|
|
||||||
После нескольких завершённых волн cleanup и refactor в проекте остались два типа проблем:
|
|
||||||
|
|
||||||
- часть архитектурного долга всё ещё открыта, но описана фрагментами в разных backlog-документах;
|
|
||||||
- часть документации отстаёт от фактического состояния кода и уже закрытых feature-итераций.
|
|
||||||
|
|
||||||
До этой фичи backlog был распределён между `frontend-debt-audit`, `backend-architecture-refactor`,
|
|
||||||
`roadmap` и отдельным worktree-драфтом `architecture-quality-backlog`. Из-за этого было неочевидно,
|
|
||||||
что уже закрыто, что требует только синхронизации docs, а что должно стать следующими отдельными
|
|
||||||
refactor-фичами.
|
|
||||||
|
|
||||||
Эта фича не меняет runtime-поведение. Она фиксирует canonical architecture backlog и приводит
|
|
||||||
source-of-truth документы к одному состоянию.
|
|
||||||
|
|
||||||
## Цель
|
|
||||||
|
|
||||||
Собрать и опубликовать единый архитектурный backlog проекта, синхронизировать roadmap и связанные
|
|
||||||
audit/refactor docs и определить нормализованный порядок следующих follow-up фич без изменения кода.
|
|
||||||
|
|
||||||
## Требования
|
|
||||||
|
|
||||||
### 1. Инвентаризация текущего состояния
|
|
||||||
|
|
||||||
Нужно сверить:
|
|
||||||
|
|
||||||
- `docs/features/frontend-debt-audit/spec.md`;
|
|
||||||
- `docs/features/backend-architecture-refactor/spec.md` и `tasks.md`;
|
|
||||||
- `docs/roadmap.md`;
|
|
||||||
- опубликованные архитектурные документы в `apps/docs/docs/frontend/` и `apps/docs/docs/backend/`.
|
|
||||||
|
|
||||||
### 2. Удаление устаревших и уже закрытых пунктов
|
|
||||||
|
|
||||||
Backlog не должен повторно открывать задачи, которые уже закрыты отдельными feature-итерациями.
|
|
||||||
Минимум нужно убрать или переотнести:
|
|
||||||
|
|
||||||
- `API envelope double-wrapping` после `api-envelope-contract`;
|
|
||||||
- первичное production config/error masking hardening после `backend-architecture-refactor`;
|
|
||||||
- устаревшие статусы, где roadmap или spec остались в промежуточном состоянии.
|
|
||||||
|
|
||||||
### 3. Публикация нормализованного backlog
|
|
||||||
|
|
||||||
Оставшийся architecture backlog должен быть опубликован как пять независимых направлений, чтобы каждое
|
|
||||||
можно было позже оформить отдельной feature-spec:
|
|
||||||
|
|
||||||
1. **T-Bank Data Isolation and Ownership Boundaries**
|
|
||||||
2. **Backend Runtime and Auth Hardening**
|
|
||||||
3. **Local T-Bank Read Path**
|
|
||||||
4. **Session Model for Multiple Surfaces**
|
|
||||||
5. **Contract, Type-Safety, and Frontend Delivery Hardening**
|
|
||||||
|
|
||||||
### 4. Явные границы каждого backlog item
|
|
||||||
|
|
||||||
Для каждого направления должны быть описаны:
|
|
||||||
|
|
||||||
- цель;
|
|
||||||
- риск;
|
|
||||||
- зависимости;
|
|
||||||
- ожидаемая граница follow-up фичи;
|
|
||||||
- причина, почему item не закрывается в рамках текущей docs-only фичи.
|
|
||||||
|
|
||||||
### 5. Никаких runtime-изменений
|
|
||||||
|
|
||||||
Фича не должна:
|
|
||||||
|
|
||||||
- менять пользовательские сценарии;
|
|
||||||
- менять API или модель данных;
|
|
||||||
- править `apps/frontend/src/**` или `apps/backend/src/**`;
|
|
||||||
- превращать backlog-публикацию в непосредственный refactor runtime-кода.
|
|
||||||
|
|
||||||
## Нормализованный backlog
|
|
||||||
|
|
||||||
### 1. T-Bank Data Isolation and Ownership Boundaries
|
|
||||||
|
|
||||||
**Цель:** изолировать T-Bank accounts, positions, operations и derived views по пользователю и закрепить
|
|
||||||
ownership boundary до дальнейших data-flow изменений.
|
|
||||||
|
|
||||||
**Риск:** без этого остаётся риск multi-tenant data leak и неявной shared ownership-модели.
|
|
||||||
|
|
||||||
**Зависимости:** текущая T-Bank integration, Prisma data model, auth identity boundary.
|
|
||||||
|
|
||||||
**Граница follow-up фичи:** backend/domain feature про ownership model, query filtering и migration-safe
|
|
||||||
изоляцию данных.
|
|
||||||
|
|
||||||
**Почему не закрыто сейчас:** требует runtime-изменений, data model decisions и отдельной спецификации.
|
|
||||||
|
|
||||||
### 2. Backend Runtime and Auth Hardening
|
|
||||||
|
|
||||||
**Цель:** закрыть оставшиеся production-grade security/runtime policy вопросы после первичной refactor
|
|
||||||
волны.
|
|
||||||
|
|
||||||
**Риск:** частично остаются недооформленные rate limiting, auth policy и surface-level security guards.
|
|
||||||
|
|
||||||
**Зависимости:** результаты `backend-architecture-refactor`, текущая auth/session модель,
|
|
||||||
deployment/runtime env policy.
|
|
||||||
|
|
||||||
**Граница follow-up фичи:** backend/security feature без смешивания с multi-tenancy или session redesign.
|
|
||||||
|
|
||||||
**Почему не закрыто сейчас:** требует отдельной threat-model driven runtime работы, а не docs sync.
|
|
||||||
|
|
||||||
### 3. Local T-Bank Read Path
|
|
||||||
|
|
||||||
**Цель:** перевести чтение операций и истории на локальную persisted модель вместо прямого read-through в
|
|
||||||
T-Bank там, где это необходимо для стабильности и контроля данных.
|
|
||||||
|
|
||||||
**Риск:** текущая зависимость от external read path усложняет устойчивость, latency control и auditability.
|
|
||||||
|
|
||||||
**Зависимости:** ownership boundary для T-Bank данных, sync model, persisted operation schema.
|
|
||||||
|
|
||||||
**Граница follow-up фичи:** backend data-flow feature про local persistence, sync and read model.
|
|
||||||
|
|
||||||
**Почему не закрыто сейчас:** требует runtime data-flow changes и, вероятно, schema/runtime planning.
|
|
||||||
|
|
||||||
### 4. Session Model for Multiple Surfaces
|
|
||||||
|
|
||||||
**Цель:** определить явную session model для web/PWA/extension-like surfaces, включая device-level
|
|
||||||
sessions, rotation и reuse detection.
|
|
||||||
|
|
||||||
**Риск:** текущая модель остаётся слишком узкой для нескольких клиентских поверхностей и finer-grained
|
|
||||||
session control.
|
|
||||||
|
|
||||||
**Зависимости:** auth boundary, refresh-token policy, future surface strategy.
|
|
||||||
|
|
||||||
**Граница follow-up фичи:** auth/session feature без смешивания с общим backend security backlog.
|
|
||||||
|
|
||||||
**Почему не закрыто сейчас:** требует product/security decisions и отдельного session contract.
|
|
||||||
|
|
||||||
### 5. Contract, Type-Safety, and Frontend Delivery Hardening
|
|
||||||
|
|
||||||
**Цель:** сократить остаточную type unsafety, стабилизировать frontend API/query boundaries и усилить
|
|
||||||
quality/performance gates.
|
|
||||||
|
|
||||||
**Риск:** остаются `as any`, слабые boundary-contracts, нет полного покрытия performance/testing gates.
|
|
||||||
|
|
||||||
**Зависимости:** существующий API envelope contract, generated types, frontend routing/query architecture.
|
|
||||||
|
|
||||||
**Граница follow-up фичи:** набор узких quality-hardening features, начиная с type-safety и frontend
|
|
||||||
delivery constraints, без смешивания с backend domain refactor.
|
|
||||||
|
|
||||||
**Почему не закрыто сейчас:** это уже не backlog sync, а серия отдельных implementation changes.
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Только `docs/` и согласование опубликованных архитектурных описаний с backlog.
|
|
||||||
- Не дублировать один и тот же долг в нескольких независимых backlog-списках.
|
|
||||||
- Не создавать новый epic только ради этой docs-only нормализации.
|
|
||||||
|
|
||||||
## Критерии приемки
|
|
||||||
|
|
||||||
- В основном workspace существует canonical feature `docs/features/architecture-quality-backlog/`.
|
|
||||||
- `docs/roadmap.md` отражает эту фичу и использует тот же нормализованный порядок backlog-направлений.
|
|
||||||
- `frontend-debt-audit` и `backend-architecture-refactor` больше не противоречат текущему состоянию
|
|
||||||
закрытых работ.
|
|
||||||
- Для каждого backlog item указаны цель, риск, зависимости, follow-up boundary и причина defer.
|
|
||||||
- В рамках фичи не изменены runtime-файлы `apps/frontend/src/**` и `apps/backend/src/**`.
|
|
||||||
|
|
||||||
## Источники
|
|
||||||
|
|
||||||
- `docs/features/frontend-debt-audit/spec.md`
|
|
||||||
- `docs/features/backend-architecture-refactor/spec.md`
|
|
||||||
- `docs/features/backend-architecture-refactor/tasks.md`
|
|
||||||
- `docs/roadmap.md`
|
|
||||||
- `apps/docs/docs/frontend/overview.md`
|
|
||||||
- `apps/docs/docs/backend/modules.md`
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
# Architecture Quality Backlog — tasks
|
|
||||||
|
|
||||||
Статус: completed
|
|
||||||
|
|
||||||
- [x] Инвентаризировать текущие frontend/backend architecture backlog документы и completed refactor waves.
|
|
||||||
- [x] Перенести `architecture-quality-backlog` из отдельного worktree в canonical `docs/features/`.
|
|
||||||
- [x] Убрать или переотнести stale backlog items, уже закрытые отдельными feature-итерациями.
|
|
||||||
- [x] Опубликовать единый нормализованный backlog в feature docs и `docs/roadmap.md`.
|
|
||||||
- [x] Выполнить финальную consistency-проверку и подтвердить docs-only scope.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,117 +0,0 @@
|
|||||||
# Backend Architecture Refactor
|
|
||||||
|
|
||||||
Дата: 2026-06-25
|
|
||||||
Статус: выполнено
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
|
|
||||||
После аудита backend-архитектуры от 2026-06-25 и последующей фичи
|
|
||||||
`backend-architecture-improvements` большая часть первого слоя долга закрыта: общий envelope DTO,
|
|
||||||
domain exceptions, dependency-aware health checks, DI-подключение middleware и разделение
|
|
||||||
`MoexClientService`.
|
|
||||||
|
|
||||||
Оставшийся долг неоднороден. Часть пунктов требует новых продуктовых или доменных решений
|
|
||||||
(`T-Bank` multi-tenancy, локальный read-path истории операций, device sessions, ledger-модель). Эти
|
|
||||||
изменения не должны попадать в локальный рефакторинг без отдельной спецификации, потому что меняют
|
|
||||||
поведение, модель данных или threat model.
|
|
||||||
|
|
||||||
Текущая фича фиксирует только refactor-only итерацию: привести существующий backend к более
|
|
||||||
правильным архитектурным границам без добавления новых пользовательских возможностей и без изменения
|
|
||||||
публичной формы API, кроме уточнения валидации некорректных входных данных.
|
|
||||||
|
|
||||||
## Цель
|
|
||||||
|
|
||||||
Снизить backend technical debt в существующем поведении за счёт точечных архитектурных правок:
|
|
||||||
безопаснее обрабатывать ошибки, формализовать production-конфигурацию, усилить DTO-валидацию,
|
|
||||||
сузить `any` на границе T-Bank gRPC, улучшить cache metadata и синхронизировать опубликованную
|
|
||||||
backend-документацию с текущим кодом.
|
|
||||||
|
|
||||||
## Требования
|
|
||||||
|
|
||||||
### 1. Error masking для необработанных исключений
|
|
||||||
|
|
||||||
Backend не должен возвращать клиенту внутренние сообщения необработанных `Error` в ответах `500`.
|
|
||||||
Подробности должны оставаться в backend-логах. Публичный ответ для unknown/internal errors должен быть
|
|
||||||
стабильным и безопасным.
|
|
||||||
|
|
||||||
### 2. Production configuration hardening
|
|
||||||
|
|
||||||
Backend должен явно отделять dev defaults от production-конфигурации:
|
|
||||||
|
|
||||||
- production-запуск не должен молча использовать дефолтные JWT access/refresh secrets;
|
|
||||||
- CORS с credentials не должен отражать произвольный origin в production;
|
|
||||||
- список допустимых origins должен задаваться конфигурацией окружения.
|
|
||||||
|
|
||||||
### 3. DTO validation hardening
|
|
||||||
|
|
||||||
Существующие DTO должны отсеивать заведомо некорректные значения до попадания в service-layer:
|
|
||||||
|
|
||||||
- даты покупки позиции должны валидироваться как ISO/date строки;
|
|
||||||
- количество позиции не должно допускать `0` там, где service-layer уже трактует это как ошибку;
|
|
||||||
- изменения должны сохранять существующий успешный пользовательский сценарий для валидных данных.
|
|
||||||
|
|
||||||
### 4. T-Bank gRPC typed boundary
|
|
||||||
|
|
||||||
Динамическая природа protobuf/gRPC клиента должна быть локализована в одном typed boundary, чтобы
|
|
||||||
доменные broker-сервисы не приводили service clients к `any` напрямую. Цель — улучшить compile-time
|
|
||||||
границы без переписывания vendored proto contract и без изменения внешнего T-Bank API behavior.
|
|
||||||
|
|
||||||
### 5. Cache metadata consistency
|
|
||||||
|
|
||||||
Cache helper должен сохранять полезность `meta.cachedAt`: cache hit не должен выглядеть как состояние
|
|
||||||
без времени кеширования, если timestamp уже можно сохранить вместе с cached payload.
|
|
||||||
|
|
||||||
### 6. Backend documentation sync
|
|
||||||
|
|
||||||
Опубликованные docs в `apps/docs/docs/backend/` должны отражать текущее состояние backend после
|
|
||||||
закрытых refactor-работ:
|
|
||||||
|
|
||||||
- split MOEX clients вместо устаревшего `MoexClientService` как единого God Service;
|
|
||||||
- актуальный envelope contract;
|
|
||||||
- актуальные health response и broker operations sync endpoint.
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Не добавлять новые user-facing возможности.
|
|
||||||
- Не менять публичный API shape для успешных ответов.
|
|
||||||
- Не вводить multi-tenancy или пользовательские T-Bank connections в рамках этой фичи.
|
|
||||||
- Не переводить операции T-Bank на локальный read-path в рамках этой фичи.
|
|
||||||
- Не менять модель сессий на device/session table в рамках этой фичи.
|
|
||||||
- Не менять финансовую модель хранения (`Float`, `Int`, JSON/string fields) и не создавать ledger ADR в
|
|
||||||
рамках этой фичи.
|
|
||||||
- Не выполнять механическое дробление больших сервисов без проверяемой архитектурной цели.
|
|
||||||
- Не редактировать Prisma migrations вручную.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- Необработанные backend exceptions логируются, но `500` response не раскрывает внутренний
|
|
||||||
`Error.message`.
|
|
||||||
- Production-конфигурация не стартует с дефолтными JWT secrets и не использует wildcard/reflected CORS
|
|
||||||
для credentialed requests.
|
|
||||||
- DTO портфельных позиций валидируют даты и количество на boundary-уровне; добавлены regression tests.
|
|
||||||
- Production-код broker-сервисов не содержит прямых `as any` для получения T-Bank gRPC service clients;
|
|
||||||
небезопасное приведение, если оно необходимо, локализовано и покрыто типом/facade.
|
|
||||||
- Cache metadata на hit/miss согласована тестами и не ломает `ApiEnvelopePayload`/`ApiResponse` contract.
|
|
||||||
- Backend docs обновлены и не ссылаются на удалённый `MoexClientService` как primary abstraction,
|
|
||||||
устаревший `/operations/refresh` endpoint или raw health response без envelope.
|
|
||||||
- Затронутые backend tests проходят.
|
|
||||||
- Backend build проходит.
|
|
||||||
- OpenAPI/types обновляются только если реально меняется Swagger contract; `apps/frontend/src/api/types.ts`
|
|
||||||
не редактируется вручную.
|
|
||||||
|
|
||||||
## Out Of Scope
|
|
||||||
|
|
||||||
- T-Bank data isolation and multi-tenancy.
|
|
||||||
- Local T-Bank operations read-path.
|
|
||||||
- Device-level sessions, refresh-token rotation и reuse detection.
|
|
||||||
- Ledger/financial data model migration.
|
|
||||||
- Rate limiting, CSRF и security headers, если они требуют отдельной threat model или middleware policy.
|
|
||||||
- Декомпозиция `PortfolioService` или `tbank/` на новые модули без отдельного плана.
|
|
||||||
|
|
||||||
## Источники
|
|
||||||
|
|
||||||
- `docs/research/2026-06-25-backend-audit.md`
|
|
||||||
- `docs/features/backend-architecture-improvements/spec.md`
|
|
||||||
- `docs/features/moex-client-split/spec.md`
|
|
||||||
- `docs/features/api-envelope-contract/spec.md`
|
|
||||||
- `docs/inbox.md` — раздел «Технический долг — кандидат на следующую итерацию»
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
# Backend Architecture Refactor — Tasks
|
|
||||||
|
|
||||||
Статус: done
|
|
||||||
|
|
||||||
- [x] Task 0: Baseline verification before runtime changes.
|
|
||||||
- [x] Task 1: Mask unhandled `500` errors without changing `HttpException` responses.
|
|
||||||
- [x] Task 2: Harden production runtime config for JWT secrets and credentialed CORS.
|
|
||||||
- [x] Task 3: Harden portfolio position DTO validation for quantity and buyDate.
|
|
||||||
- [x] Task 4: Localize T-Bank gRPC `any` casts behind typed facade methods.
|
|
||||||
- [x] Task 5: Preserve cache `cachedAt` metadata on cache hits.
|
|
||||||
- [x] Task 6: Synchronize published backend docs with current MOEX/envelope/health/T-Bank contracts.
|
|
||||||
- [x] Task 7: Run final lint/test/build/docs quality gate and inspect final diff.
|
|
||||||
|
|
||||||
## Verification Log
|
|
||||||
|
|
||||||
- Baseline backend tests: 30 files, 141 tests — PASS.
|
|
||||||
- Baseline backend build: PASS.
|
|
||||||
- Final backend lint: PASS.
|
|
||||||
- Final backend tests: 34 files, 158 tests — PASS.
|
|
||||||
- Final backend build: PASS.
|
|
||||||
- Final docs build: PASS.
|
|
||||||
- No `getServiceClient('X') as any` in production broker services: 0 matches.
|
|
||||||
- No `MoexClientService` or `operations/refresh` in docs: 0 matches.
|
|
||||||
@ -1,329 +0,0 @@
|
|||||||
# Финальный HTML Parity брокерского overview — план реализации
|
|
||||||
|
|
||||||
> **Для агентных исполнителей:** ОБЯЗАТЕЛЬНЫЙ SUB-SKILL: использовать
|
|
||||||
> `superpowers:subagent-driven-development` (предпочтительно) или `superpowers:executing-plans` для
|
|
||||||
> пошагового выполнения. Шаги ведутся чекбоксами `- [ ]`.
|
|
||||||
|
|
||||||
**Цель:** привести `/broker/:accountId` к финальному HTML-эталону
|
|
||||||
`docs/research/frontend-overview-redesign/example.html` без изменения URL-структуры счёта.
|
|
||||||
|
|
||||||
**Архитектура:** backend расширяет существующий broker read API минимальными агрегатами и историей
|
|
||||||
стоимости. Frontend остаётся в FSD-границах `entities/broker-*`, `widgets/broker-dashboard`,
|
|
||||||
`widgets/broker-account-layout`; dashboard-композиция меняется с промежуточной версии на финальную
|
|
||||||
HTML parity структуру.
|
|
||||||
|
|
||||||
**Технологии:** NestJS, Prisma/T-Bank broker operations read path, Swagger/OpenAPI codegen, React 18,
|
|
||||||
TanStack Query, TanStack Router, MUI + `@moex-vibe/design-system`, Vitest, Testing Library.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Canonical Research Source
|
|
||||||
|
|
||||||
- Использовать как visual source of truth:
|
|
||||||
`docs/research/frontend-overview-redesign/example.html`.
|
|
||||||
- Не использовать как source of truth:
|
|
||||||
`docs/research/2026-06-27-broker-account-redesign.html`, пока файл не синхронизирован с финальным
|
|
||||||
макетом.
|
|
||||||
- Production UI не переносит demo-control `Данные / Загрузка`; этот control нужен только HTML-макету.
|
|
||||||
|
|
||||||
## Файлы
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- Modify: `apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts` — добавить
|
|
||||||
`totalFees`, `totalTaxesPaid`.
|
|
||||||
- Create: `apps/backend/src/modules/tbank/dto/broker-portfolio-history-response.dto.ts` — DTO для
|
|
||||||
6-месячной истории стоимости.
|
|
||||||
- Modify: `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts` — envelope для history endpoint.
|
|
||||||
- Modify: `apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts` — query `categories`.
|
|
||||||
- Modify: `apps/backend/src/modules/tbank/services/broker-analytics.service.ts` — агрегировать fee/tax.
|
|
||||||
- Create: `apps/backend/src/modules/tbank/services/broker-portfolio-history.service.ts` — read-model
|
|
||||||
истории стоимости.
|
|
||||||
- Modify: `apps/backend/src/modules/tbank/services/broker-operations.service.ts` — применять
|
|
||||||
category-фильтр до ответа.
|
|
||||||
- Modify: `apps/backend/src/modules/tbank/tbank.controller.ts` — endpoint portfolio history.
|
|
||||||
- Modify: backend tests рядом с изменёнными сервисами/controller.
|
|
||||||
|
|
||||||
### Frontend data
|
|
||||||
|
|
||||||
- Modify: `apps/frontend/src/shared/api/index.ts` — экспорт нового `BrokerPortfolioHistory`.
|
|
||||||
- Create: `apps/frontend/src/entities/broker-account/api/brokerPortfolioHistoryApi.ts`.
|
|
||||||
- Create: `apps/frontend/src/entities/broker-account/model/useBrokerPortfolioHistory.ts`.
|
|
||||||
- Modify: `apps/frontend/src/entities/broker-account/index.ts` — экспорт hook/API.
|
|
||||||
- Modify: `apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts` — query `categories`.
|
|
||||||
|
|
||||||
### Frontend UI
|
|
||||||
|
|
||||||
- Modify: `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx` — title yield
|
|
||||||
рядом с заголовком счёта, без page-header skeleton.
|
|
||||||
- Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` — финальный порядок блоков
|
|
||||||
и источники данных.
|
|
||||||
- Replace/Remove: `BrokerDashboardHero.tsx` из overview-композиции; файл можно оставить только если
|
|
||||||
больше используется в тестах/экспортах, но в `/broker/:accountId` он не рендерится.
|
|
||||||
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerPortfolioHistoryCard.tsx`.
|
|
||||||
- Modify: `BrokerDashboardAnalyticsCard.tsx`, `BrokerDashboardAllocationCard.tsx`,
|
|
||||||
`BrokerDashboardEventsCard.tsx`, `BrokerDashboardSkeleton.tsx`.
|
|
||||||
- Modify/Create helpers в `apps/frontend/src/widgets/broker-dashboard/lib/` для chart points, latest
|
|
||||||
event rows, analytics display и visual tones.
|
|
||||||
- Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` и helper unit tests.
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
|
|
||||||
- Modify: `docs/features/broker-account-overview-html-parity/tasks.md` по факту выполнения.
|
|
||||||
- Не переписывать `docs/features/broker-dashboard-redesign/*`; старая фича остаётся историей
|
|
||||||
промежуточной итерации.
|
|
||||||
|
|
||||||
## Data Contracts
|
|
||||||
|
|
||||||
### Analytics
|
|
||||||
|
|
||||||
`BrokerAnalyticsDto` расширяется:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
totalFees: number
|
|
||||||
totalTaxesPaid: number
|
|
||||||
```
|
|
||||||
|
|
||||||
Расчёт:
|
|
||||||
|
|
||||||
- `totalFees` = сумма absolute payment values executed операций категории `fee`;
|
|
||||||
- `totalTaxesPaid` = сумма absolute payment values executed операций категории `tax`;
|
|
||||||
- значения возвращаются как положительные агрегаты;
|
|
||||||
- frontend отображает их со знаком минус и negative tone.
|
|
||||||
|
|
||||||
### Portfolio History
|
|
||||||
|
|
||||||
Endpoint:
|
|
||||||
|
|
||||||
```text
|
|
||||||
GET /api/v1/broker/accounts/:accountId/portfolio/history?months=6
|
|
||||||
```
|
|
||||||
|
|
||||||
Response data:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type BrokerPortfolioHistoryData = {
|
|
||||||
accountId: string
|
|
||||||
points: Array<{
|
|
||||||
month: string
|
|
||||||
label: string
|
|
||||||
value: BrokerMoneyDto
|
|
||||||
}>
|
|
||||||
asOf: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Правила v1:
|
|
||||||
|
|
||||||
- `months` по умолчанию 6, допустимый диапазон 2-12;
|
|
||||||
- `points.length === months`;
|
|
||||||
- `month` в формате `YYYY-MM`;
|
|
||||||
- `label` — короткое русское имя месяца;
|
|
||||||
- последняя точка равна текущей `portfolio.totals.portfolio`;
|
|
||||||
- предыдущие точки могут быть estimated read-model из текущей стоимости и executed операций за период;
|
|
||||||
- контракт должен позволять позже заменить estimated calculation на persisted snapshots.
|
|
||||||
|
|
||||||
### Operation Categories
|
|
||||||
|
|
||||||
`BrokerOperationQueryDto` получает:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
categories?: string
|
|
||||||
```
|
|
||||||
|
|
||||||
Правила:
|
|
||||||
|
|
||||||
- comma-separated значения из `trade,income,tax,fee,transfer,other`;
|
|
||||||
- неизвестные категории игнорировать или валидировать с `400`; выбрать один вариант и покрыть тестом;
|
|
||||||
- filtering выполняется до формирования page response;
|
|
||||||
- overview `Последние события` запрашивает `categories=income,tax,fee`, `state=OPERATION_STATE_EXECUTED`,
|
|
||||||
`limit=7`.
|
|
||||||
|
|
||||||
## Implementation Tasks
|
|
||||||
|
|
||||||
### Task 1: Docs and research alignment
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `docs/features/broker-account-overview-html-parity/spec.md`
|
|
||||||
- `docs/features/broker-account-overview-html-parity/plan.md`
|
|
||||||
- `docs/features/broker-account-overview-html-parity/tasks.md`
|
|
||||||
|
|
||||||
- Проверить, что docs ссылаются на `docs/research/frontend-overview-redesign/example.html`.
|
|
||||||
- Проверить, что docs явно запрещают использовать старый dated HTML как canonical reference.
|
|
||||||
- Зафиксировать финальный порядок блоков и отсутствие production demo-toggle.
|
|
||||||
|
|
||||||
### Task 2: Backend analytics contract
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts`
|
|
||||||
- `apps/backend/src/modules/tbank/services/broker-analytics.service.ts`
|
|
||||||
- `apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts`
|
|
||||||
- `apps/backend/src/modules/tbank/tbank.controller.spec.ts`
|
|
||||||
|
|
||||||
- Добавить `totalFees`, `totalTaxesPaid` в DTO и тестовый response.
|
|
||||||
- Расширить analytics service наборами fee/tax типов через существующую категоризацию операций.
|
|
||||||
- Считать fee/tax только по executed операциям с `payment`.
|
|
||||||
- Возвращать округление до копеек аналогично текущим analytics агрегатам.
|
|
||||||
|
|
||||||
### Task 3: Backend portfolio history endpoint
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `apps/backend/src/modules/tbank/dto/broker-portfolio-history-response.dto.ts`
|
|
||||||
- `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts`
|
|
||||||
- `apps/backend/src/modules/tbank/services/broker-portfolio-history.service.ts`
|
|
||||||
- `apps/backend/src/modules/tbank/tbank.controller.ts`
|
|
||||||
- `apps/backend/src/modules/tbank/tbank.controller.spec.ts`
|
|
||||||
|
|
||||||
- Добавить DTO для history point и envelope.
|
|
||||||
- Добавить service, который проверяет account access через `BrokerAccountsService`.
|
|
||||||
- Получить текущую стоимость через existing portfolio service или общий read path без дублирования T-Bank
|
|
||||||
вызовов сверх необходимого.
|
|
||||||
- Вернуть 6 monthly points для default query.
|
|
||||||
- Покрыть default months, invalid account и shape response тестами.
|
|
||||||
|
|
||||||
### Task 4: Backend operation category filtering
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `apps/backend/src/modules/tbank/dto/broker-operation-query.dto.ts`
|
|
||||||
- `apps/backend/src/modules/tbank/services/broker-operations.service.ts`
|
|
||||||
- `apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts`
|
|
||||||
|
|
||||||
- Добавить `categories` query.
|
|
||||||
- Применить category filtering к mapped operations page перед отдачей response.
|
|
||||||
- Если T-Bank page может содержать меньше 7 подходящих операций после фильтрации, documented v1 поведение:
|
|
||||||
endpoint возвращает подходящие операции из текущей fetched page; full backfill pagination не требуется.
|
|
||||||
- Покрыть `categories=income,tax,fee` и неизвестную категорию тестом.
|
|
||||||
|
|
||||||
### Task 5: OpenAPI and frontend generated types
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `apps/frontend/src/shared/api/types.ts`
|
|
||||||
- `apps/frontend/src/shared/api/index.ts`
|
|
||||||
|
|
||||||
- Запустить backend dev server.
|
|
||||||
- Выполнить `npm run codegen -w apps/frontend`.
|
|
||||||
- Не редактировать generated `types.ts` вручную.
|
|
||||||
- Экспортировать новые frontend aliases из `shared/api/index.ts`.
|
|
||||||
- Проверить, что generated schemas содержат `totalFees`, `totalTaxesPaid`,
|
|
||||||
`BrokerPortfolioHistoryDataDto`.
|
|
||||||
|
|
||||||
### Task 6: Frontend data hooks
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `apps/frontend/src/entities/broker-account/api/brokerPortfolioHistoryApi.ts`
|
|
||||||
- `apps/frontend/src/entities/broker-account/model/useBrokerPortfolioHistory.ts`
|
|
||||||
- `apps/frontend/src/entities/broker-account/index.ts`
|
|
||||||
- `apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts`
|
|
||||||
|
|
||||||
- Добавить `getBrokerPortfolioHistory(accountId, { months })`.
|
|
||||||
- Добавить `useBrokerPortfolioHistory(accountId, { months: 6 })` с query key
|
|
||||||
`['broker', 'portfolio-history', accountId, months]`.
|
|
||||||
- Добавить `categories` в `BrokerOperationQuery`.
|
|
||||||
- Не менять существующие hooks detailed вкладок.
|
|
||||||
|
|
||||||
### Task 7: Page title yield
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx`
|
|
||||||
- `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
|
||||||
|
|
||||||
- Перенести compact yield UI рядом с `Брокерский счёт`.
|
|
||||||
- Убрать отдельный hero KPI из overview.
|
|
||||||
- Не показывать page-title skeleton в loading state.
|
|
||||||
- Сохранить доступность: доходность имеет `aria-label="Доходность счёта"` или эквивалент.
|
|
||||||
|
|
||||||
### Task 8: Portfolio history card
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerPortfolioHistoryCard.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardPortfolioHistory.ts`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx`
|
|
||||||
|
|
||||||
- Создать карточку `Стоимость портфеля за 6 месяцев`.
|
|
||||||
- Нарисовать SVG line/area chart без visible point markers.
|
|
||||||
- Использовать 6 backend points и подписи месяцев из response.
|
|
||||||
- Сделать loading chart indicator без skeleton месяцев.
|
|
||||||
- Обеспечить одинаковую min-height loaded/loading.
|
|
||||||
- Первая и последняя точки графика должны совпадать с краями области.
|
|
||||||
|
|
||||||
### Task 9: Analytics card parity
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`
|
|
||||||
|
|
||||||
- Summary row: `Стоимость портфеля`, `Всего доходов`.
|
|
||||||
- Detail grid: `Пополнения`, `Выводы`, `Дивиденды`, `Купоны`, `Комиссия`,
|
|
||||||
`Уплаченные налоги`.
|
|
||||||
- Удалить `Нетто` и `Всего получено` из overview-card.
|
|
||||||
- Отображать `totalFees` и `totalTaxesPaid` как negative UI amounts.
|
|
||||||
- Сохранить skeleton геометрию 2 + 6 карточек.
|
|
||||||
|
|
||||||
### Task 10: Allocation card parity
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx`
|
|
||||||
- `apps/frontend/src/entities/broker-position/model/brokerAllocation.ts`
|
|
||||||
|
|
||||||
- Переименовать карточку в `Структура`.
|
|
||||||
- Убрать subtitle `Структура портфеля`.
|
|
||||||
- Показать итоговую стоимость под заголовком.
|
|
||||||
- Отобразить только строки `Акции`, `Облигации`, `Деньги` для overview parity.
|
|
||||||
- Сохранить корректное поведение для отсутствующих/нулевых значений.
|
|
||||||
|
|
||||||
### Task 11: Latest events card parity
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardEvents.ts`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
|
||||||
|
|
||||||
- Переключить overview source с `useBrokerEvents` на `useBrokerOperations` с
|
|
||||||
`categories=income,tax,fee`, `limit=7`.
|
|
||||||
- Убрать filters toolbar, count badge, footer summary и колонку `Статус`.
|
|
||||||
- Переименовать карточку в `Последние события`.
|
|
||||||
- Отсортировать rows новые → старые.
|
|
||||||
- Инструмент: название сверху жирным, ticker/ISIN снизу серым.
|
|
||||||
- Тип: бейдж `Дивиденд`, `Купон`, `Погашение`, `Налог`, `Комиссия`.
|
|
||||||
- Налоговые/комиссионные/отрицательные операции отображать красным.
|
|
||||||
|
|
||||||
### Task 12: Tests, build, visual QA
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`
|
|
||||||
- backend specs из предыдущих задач
|
|
||||||
- `docs/features/broker-account-overview-html-parity/tasks.md`
|
|
||||||
|
|
||||||
- Backend targeted tests:
|
|
||||||
`npm run test -w apps/backend -- src/modules/tbank/services/broker-analytics.service.spec.ts src/modules/tbank/services/broker-operations.service.spec.ts src/modules/tbank/tbank.controller.spec.ts`
|
|
||||||
- Frontend targeted tests:
|
|
||||||
`npm run test -w apps/frontend -- --run src/widgets/broker-dashboard`
|
|
||||||
- Full checks:
|
|
||||||
`npm run test:frontend`
|
|
||||||
`npm run lint -w apps/frontend`
|
|
||||||
`npm run build:frontend`
|
|
||||||
- Visual QA:
|
|
||||||
desktop `/broker/:accountId`;
|
|
||||||
mobile viewport `390x844`;
|
|
||||||
compare against `docs/research/frontend-overview-redesign/example.html`.
|
|
||||||
- Update `tasks.md` statuses and notes after verification.
|
|
||||||
|
|
||||||
## Risks and Decisions
|
|
||||||
|
|
||||||
- History chart v1 uses estimated read-model; exact historical market value requires future snapshots.
|
|
||||||
- Category filtering after one fetched T-Bank page may underfill latest events; acceptable for v1 unless
|
|
||||||
testing shows too many empty overview rows.
|
|
||||||
- The HTML mock uses static values. React must match structure and visual behavior, not literal amounts.
|
|
||||||
- `Последние события` is intentionally based on executed operations, not calendar events, because the
|
|
||||||
final HTML shows already happened cashflow rows and removes status/forecast semantics.
|
|
||||||
|
|
||||||
## Verification Matrix
|
|
||||||
|
|
||||||
- Spec requirements map to tasks 2-11.
|
|
||||||
- Backend API requirements map to tasks 2-5.
|
|
||||||
- Frontend order and visual parity map to tasks 7-11.
|
|
||||||
- Loading stability and mobile overflow are verified in task 12.
|
|
||||||
@ -1,208 +0,0 @@
|
|||||||
# Финальный редизайн обзора брокерского счёта по HTML-эталону
|
|
||||||
|
|
||||||
Дата: 2026-06-27
|
|
||||||
Статус: спецификация подготовлена
|
|
||||||
Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md)
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
|
|
||||||
Маршрут `/broker/:accountId` уже был переведён на dashboard-композицию в рамках
|
|
||||||
`docs/features/broker-dashboard-redesign`, но эта итерация отражает промежуточный вариант:
|
|
||||||
`Hero → События → Доходы → Аналитика доходности → Аллокация`.
|
|
||||||
|
|
||||||
После интерактивной дизайн-итерации пользователь согласовал финальный HTML-эталон в
|
|
||||||
`docs/research/frontend-overview-redesign/example.html`. Именно этот файл является canonical visual
|
|
||||||
reference для текущей фичи. Файл `docs/research/2026-06-27-broker-account-redesign.html` содержит более
|
|
||||||
раннюю версию и не должен использоваться как источник истины, пока не будет синхронизирован.
|
|
||||||
|
|
||||||
Финальный экран должен выглядеть как HTML-эталон, но работать на реальных данных приложения,
|
|
||||||
существующих FSD-границах, backend API и текущей светлой теме MoexVibe.
|
|
||||||
|
|
||||||
## Цель
|
|
||||||
|
|
||||||
Переделать обзор брокерского счёта `/broker/:accountId` так, чтобы production React-страница визуально
|
|
||||||
и поведенчески соответствовала финальному HTML-эталону:
|
|
||||||
|
|
||||||
`Заголовок счёта → Стоимость портфеля за 6 месяцев → Аналитика доходности → Структура → Последние события`.
|
|
||||||
|
|
||||||
## Пользовательский результат
|
|
||||||
|
|
||||||
Пользователь на `/broker/:accountId` может:
|
|
||||||
|
|
||||||
- сразу увидеть название счёта, текущую доходность и дневное изменение;
|
|
||||||
- увидеть график стоимости портфеля за последние 6 месяцев;
|
|
||||||
- увидеть ключевые показатели доходности, включая комиссии и уплаченные налоги;
|
|
||||||
- увидеть структуру портфеля в компактном bar-chart виде;
|
|
||||||
- увидеть последние уже произошедшие события/денежные операции по счёту;
|
|
||||||
- перейти в подробные вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика`.
|
|
||||||
|
|
||||||
## Область изменений
|
|
||||||
|
|
||||||
Входит в scope:
|
|
||||||
|
|
||||||
- frontend-маршрут `/broker/:accountId` и виджеты `widgets/broker-dashboard`;
|
|
||||||
- layout заголовка счёта в `widgets/broker-account-layout`;
|
|
||||||
- backend read endpoints брокерского домена, необходимые для честного отображения HTML parity;
|
|
||||||
- OpenAPI/codegen для новых или расширенных DTO;
|
|
||||||
- тесты backend/frontend и визуальная проверка desktop/mobile.
|
|
||||||
|
|
||||||
Не входит в scope:
|
|
||||||
|
|
||||||
- редизайн подробных вкладок `Акции`, `Облигации`, `Операции`, `События`, `Аналитика`;
|
|
||||||
- dark mode;
|
|
||||||
- demo-переключатель `Данные / Загрузка` из HTML-эталона;
|
|
||||||
- полноценное хранилище исторических снапшотов портфеля;
|
|
||||||
- изменение URL-структуры `/broker/:accountId/*`.
|
|
||||||
|
|
||||||
## Требования
|
|
||||||
|
|
||||||
### 1. Общая композиция
|
|
||||||
|
|
||||||
- `/broker/:accountId` остаётся overview выбранного брокерского счёта.
|
|
||||||
- Страница использует порядок блоков из HTML-эталона:
|
|
||||||
`Заголовок счёта`, `Стоимость портфеля за 6 месяцев`, `Аналитика доходности`, `Структура`,
|
|
||||||
`Последние события`.
|
|
||||||
- Отдельный hero KPI-блок удаляется из overview-композиции.
|
|
||||||
- Блок `Доходы` удаляется из overview-композиции. Подробные доходные операции остаются доступны через
|
|
||||||
вкладку `Операции`.
|
|
||||||
- Существующая навигация счёта остаётся горизонтальными вкладками над dashboard-контентом.
|
|
||||||
- На desktop и mobile блоки идут одной колонкой в одинаковом порядке.
|
|
||||||
- Production UI не содержит demo-toggle `Данные / Загрузка`.
|
|
||||||
|
|
||||||
### 2. Заголовок счёта
|
|
||||||
|
|
||||||
- Заголовок показывает название счёта или fallback `Брокерский счёт`.
|
|
||||||
- Справа от заголовка, визуально меньшим блоком, показывается доходность счёта:
|
|
||||||
`Доходность`, значение процента и supporting text `За день: ...`.
|
|
||||||
- Доходность берётся из `analytics.totalReturnPercent`, если доступна, иначе из
|
|
||||||
`portfolio.yields.expectedPercent`, иначе отображается `—`.
|
|
||||||
- Дневное изменение берётся из `portfolio.yields.daily`, иначе отображается `—`.
|
|
||||||
- Отрицательная доходность окрашивается красным, положительная зелёным, недоступная нейтральным.
|
|
||||||
- В loading-состоянии заголовок не показывает skeleton-полосы. Layout должен сохранять стабильную
|
|
||||||
высоту и не прыгать после загрузки.
|
|
||||||
|
|
||||||
### 3. Стоимость портфеля за 6 месяцев
|
|
||||||
|
|
||||||
- Первый dashboard-блок называется `Стоимость портфеля за 6 месяцев`.
|
|
||||||
- Карточка использует зелёный градиентный фон и тонкую зелёную рамку как в HTML-эталоне.
|
|
||||||
- В карточке отображаются:
|
|
||||||
- label `Текущая стоимость`;
|
|
||||||
- текущая стоимость портфеля из `portfolio.totals.portfolio`;
|
|
||||||
- плавный line/area chart по 6 месячным точкам.
|
|
||||||
- График показывает ровно 6 подписей месяцев и 6 значений, соответствующих этим месяцам.
|
|
||||||
- Первая точка линии начинается у левого края области графика, последняя — у правого края.
|
|
||||||
- Visible point markers не отображаются; линия плавная.
|
|
||||||
- Подписи месяцев не выходят за границы карточки.
|
|
||||||
- Loading-состояние графика использует chart-like indicator без skeleton-подписей месяцев.
|
|
||||||
- Высота карточки в loading и loaded состояниях должна совпадать.
|
|
||||||
|
|
||||||
### 4. Аналитика доходности
|
|
||||||
|
|
||||||
- Блок называется `Аналитика доходности`.
|
|
||||||
- Верхний summary-row содержит только:
|
|
||||||
- `Стоимость портфеля`;
|
|
||||||
- `Всего доходов`.
|
|
||||||
- Detail-grid содержит ровно:
|
|
||||||
- `Пополнения`;
|
|
||||||
- `Выводы`;
|
|
||||||
- `Дивиденды`;
|
|
||||||
- `Купоны`;
|
|
||||||
- `Комиссия`;
|
|
||||||
- `Уплаченные налоги`.
|
|
||||||
- Поля `Нетто` и `Всего получено` не отображаются в overview-карточке.
|
|
||||||
- RUB отображается как `₽`, а не `RUB`.
|
|
||||||
- Положительные финансовые значения окрашиваются зелёным, отрицательные/уменьшающие баланс —
|
|
||||||
красным.
|
|
||||||
- `Комиссия` и `Уплаченные налоги` отображаются как отрицательные UI-суммы, даже если backend хранит
|
|
||||||
агрегат абсолютным положительным числом.
|
|
||||||
- Loading-состояние использует skeleton-карточки той же геометрии, чтобы интерфейс не прыгал.
|
|
||||||
|
|
||||||
### 5. Структура
|
|
||||||
|
|
||||||
- Блок называется `Структура`.
|
|
||||||
- Под заголовком показывается итоговая стоимость портфеля.
|
|
||||||
- Под итогом отображаются горизонтальные бары:
|
|
||||||
- `Акции`;
|
|
||||||
- `Облигации`;
|
|
||||||
- `Деньги`.
|
|
||||||
- Каждая строка показывает название сектора, сумму и процент.
|
|
||||||
- Цвета соответствуют HTML-эталону: акции — синий, облигации — янтарный, деньги — фиолетовый.
|
|
||||||
- Если значение сектора отсутствует или равно нулю, строка остаётся читаемой и не ломает layout.
|
|
||||||
- Общая карточка не содержит subtitle `Структура портфеля`.
|
|
||||||
|
|
||||||
### 6. Последние события
|
|
||||||
|
|
||||||
- Блок называется `Последние события`.
|
|
||||||
- Блок показывает только уже произошедшие записи, которые меняют или отражают денежный поток счёта.
|
|
||||||
- Источник данных — executed broker operations с категориями `income`, `tax`, `fee`.
|
|
||||||
- Записи сортируются от новых к старым.
|
|
||||||
- В overview показывается не более 7 последних записей.
|
|
||||||
- Таблица содержит колонки:
|
|
||||||
- `Дата`;
|
|
||||||
- `Инструмент`;
|
|
||||||
- `Тип`;
|
|
||||||
- `Сумма`.
|
|
||||||
- Колонка `Статус` не отображается.
|
|
||||||
- Toolbar фильтров, count badge и footer summary не отображаются в overview-карточке.
|
|
||||||
- В колонке `Инструмент` название отображается сверху жирным, ticker/ISIN снизу серым.
|
|
||||||
- Для операций без названия основная строка берётся из ticker/figi/description без дублирования в subtitle.
|
|
||||||
- Тип операции отображается бейджем: `Дивиденд`, `Купон`, `Погашение`, `Налог`, `Комиссия` или
|
|
||||||
безопасный fallback.
|
|
||||||
- Всё, что уменьшает баланс, отображается красным: сумма, типовой бейдж и tone строки/значения.
|
|
||||||
- Для налоговых операций в колонке `Тип` должен быть бейдж `Налог`, а не исходный income/coupon label.
|
|
||||||
- Блок содержит ссылку `Все события` на подробную вкладку `/broker/:accountId/events` или согласованный
|
|
||||||
detailed cashflow route, если реализация выделит его отдельно.
|
|
||||||
- Ошибка загрузки последних событий не ломает остальные блоки.
|
|
||||||
|
|
||||||
### 7. Backend API
|
|
||||||
|
|
||||||
- `BrokerAnalyticsDto` расширяется полями:
|
|
||||||
- `totalFees: number`;
|
|
||||||
- `totalTaxesPaid: number`.
|
|
||||||
- `totalFees` считается из executed broker operations категории `fee`.
|
|
||||||
- `totalTaxesPaid` считается из executed broker operations категории `tax`.
|
|
||||||
- Оба агрегата возвращаются абсолютными положительными числами; frontend отвечает за знак отображения.
|
|
||||||
- Добавляется endpoint:
|
|
||||||
`GET /api/v1/broker/accounts/:accountId/portfolio/history?months=6`.
|
|
||||||
- Endpoint возвращает envelope с данными:
|
|
||||||
- `accountId: string`;
|
|
||||||
- `points: Array<{ month: string; label: string; value: BrokerMoneyDto }>`;
|
|
||||||
- `asOf: string`.
|
|
||||||
- `month` имеет формат `YYYY-MM`.
|
|
||||||
- `label` содержит короткое русское имя месяца для оси.
|
|
||||||
- `points.length` равен запрошенному `months`, по умолчанию 6.
|
|
||||||
- Для v1 допускается estimated read-model из текущей стоимости портфеля и executed operations. Shape
|
|
||||||
контракта должен позволять позже заменить расчёт на реальные snapshots без изменения frontend.
|
|
||||||
- `BrokerOperationQueryDto` расширяется фильтром `categories?: string`.
|
|
||||||
- `categories` принимает comma-separated категории из существующего набора:
|
|
||||||
`trade,income,tax,fee,transfer,other`.
|
|
||||||
- `GET /broker/accounts/:accountId/operations` применяет `categories` до пагинации/лимита, чтобы overview
|
|
||||||
не фильтровал неполную страницу на клиенте.
|
|
||||||
|
|
||||||
### 8. Загрузка, ошибки и пустые состояния
|
|
||||||
|
|
||||||
- Ошибка portfolio остаётся page-level ошибкой.
|
|
||||||
- Ошибка analytics, history или latest events отображается только внутри соответствующей карточки.
|
|
||||||
- Недоступные значения отображаются как `—`, не подменяются нулём.
|
|
||||||
- Loading-состояния должны сохранять высоты карточек и не вызывать layout shift.
|
|
||||||
- На mobile не должно быть общего горизонтального overflow. Горизонтальный scroll допустим только внутри
|
|
||||||
таблицы, если без него невозможно сохранить читаемость.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- `/broker/:accountId` отображает блоки в порядке:
|
|
||||||
`Стоимость портфеля за 6 месяцев`, `Аналитика доходности`, `Структура`, `Последние события`.
|
|
||||||
- Верхняя строка страницы показывает `Брокерский счёт` и доходность рядом с ним, без отдельного hero.
|
|
||||||
- Production UI не показывает demo-toggle `Данные / Загрузка`.
|
|
||||||
- График стоимости имеет 6 подписей месяцев и 6 точек данных; линия начинается слева и заканчивается
|
|
||||||
справа.
|
|
||||||
- Loading графика не содержит skeleton-подписей месяцев и имеет ту же высоту, что loaded состояние.
|
|
||||||
- Analytics overview показывает `Комиссия` и `Уплаченные налоги`, не показывает `Нетто` и
|
|
||||||
`Всего получено`.
|
|
||||||
- `Структура` показывает итоговую стоимость и бары `Акции`, `Облигации`, `Деньги`.
|
|
||||||
- `Последние события` показывает только произошедшие записи, отсортированные новые → старые.
|
|
||||||
- Таблица `Последние события` не содержит toolbar, count badge, footer summary и колонку `Статус`.
|
|
||||||
- Операции налогов/комиссий и другие списания отображаются красным.
|
|
||||||
- Backend Swagger содержит новые analytics fields, portfolio history endpoint и `categories` query.
|
|
||||||
- Frontend generated types обновлены через codegen.
|
|
||||||
- Desktop и mobile визуально соответствуют `docs/research/frontend-overview-redesign/example.html`.
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
# Финальный редизайн брокерского overview по HTML — задачи
|
|
||||||
|
|
||||||
Дата: 2026-06-27
|
|
||||||
Статус: реализация завершена
|
|
||||||
|
|
||||||
## Документация и pre-flight
|
|
||||||
|
|
||||||
- [x] Создать `docs/features/broker-account-overview-html-parity/spec.md`.
|
|
||||||
- [x] Создать `docs/features/broker-account-overview-html-parity/plan.md`.
|
|
||||||
- [x] Создать `docs/features/broker-account-overview-html-parity/tasks.md`.
|
|
||||||
- [x] Зафиксировать canonical visual reference:
|
|
||||||
`docs/research/frontend-overview-redesign/example.html`.
|
|
||||||
- [x] Зафиксировать, что `docs/research/2026-06-27-broker-account-redesign.html` не является
|
|
||||||
source of truth для этой итерации.
|
|
||||||
- [x] Зафиксировать финальный порядок блоков:
|
|
||||||
`Заголовок счёта → Стоимость портфеля за 6 месяцев → Аналитика доходности → Структура → Последние события`.
|
|
||||||
- [x] Зафиксировать, что production UI не переносит demo-toggle `Данные / Загрузка`.
|
|
||||||
- [x] Перед началом реализации убедиться, что работа идёт в feature branch.
|
|
||||||
- [x] Перед началом реализации запустить baseline checks текущей ветки.
|
|
||||||
|
|
||||||
## Backend contract
|
|
||||||
|
|
||||||
- [x] Расширить `BrokerAnalyticsDto` полями `totalFees` и `totalTaxesPaid`.
|
|
||||||
- [x] Обновить `BrokerAnalyticsService`: считать комиссии из executed операций категории `fee`.
|
|
||||||
- [x] Обновить `BrokerAnalyticsService`: считать уплаченные налоги из executed операций категории `tax`.
|
|
||||||
- [x] Обновить `broker-analytics.service.spec.ts` для новых агрегатов и округления.
|
|
||||||
- [x] Добавить DTO для `BrokerPortfolioHistoryData`.
|
|
||||||
- [x] Добавить envelope DTO для portfolio history endpoint.
|
|
||||||
- [x] Добавить `BrokerPortfolioHistoryService`.
|
|
||||||
- [x] Добавить endpoint `GET /api/v1/broker/accounts/:accountId/portfolio/history?months=6`.
|
|
||||||
- [x] Покрыть portfolio history default months и response shape тестами.
|
|
||||||
- [x] Добавить `categories?: string` в `BrokerOperationQueryDto`.
|
|
||||||
- [x] Обновить `BrokerOperationsService`: применять category filtering для operations response.
|
|
||||||
- [x] Покрыть `categories=income,tax,fee` и неизвестные категории тестами.
|
|
||||||
- [x] Обновить `TBankController` и `tbank.controller.spec.ts` под новый endpoint/DTO.
|
|
||||||
|
|
||||||
## OpenAPI и frontend data layer
|
|
||||||
|
|
||||||
- [x] Запустить backend dev server для Swagger JSON.
|
|
||||||
- [x] Выполнить `npm run codegen -w apps/frontend`.
|
|
||||||
- [x] Проверить, что generated types содержат `totalFees`, `totalTaxesPaid` и portfolio history schemas.
|
|
||||||
- [x] Экспортировать новый `BrokerPortfolioHistory` alias из `apps/frontend/src/shared/api/index.ts`.
|
|
||||||
- [x] Добавить `getBrokerPortfolioHistory`.
|
|
||||||
- [x] Добавить `useBrokerPortfolioHistory`.
|
|
||||||
- [x] Добавить `categories` в frontend `BrokerOperationQuery`.
|
|
||||||
- [x] Не редактировать `apps/frontend/src/shared/api/types.ts` вручную.
|
|
||||||
|
|
||||||
## Frontend composition
|
|
||||||
|
|
||||||
- [x] Перестроить `BrokerDashboard` на финальный порядок блоков.
|
|
||||||
- [x] Убрать `BrokerDashboardHero` из overview-render path.
|
|
||||||
- [x] Перенести compact yield UI в заголовок счёта рядом с `Брокерский счёт`.
|
|
||||||
- [x] Убедиться, что page title loading state не показывает skeleton-полосы.
|
|
||||||
- [x] Создать `BrokerPortfolioHistoryCard`.
|
|
||||||
- [x] Подключить `useBrokerPortfolioHistory(accountId, { months: 6 })`.
|
|
||||||
- [x] Заменить overview `BrokerDashboardIncomeCard` на `BrokerPortfolioHistoryCard`.
|
|
||||||
- [x] Обновить `BrokerDashboardSkeleton` под финальный порядок и стабильные высоты.
|
|
||||||
|
|
||||||
## Frontend visual parity
|
|
||||||
|
|
||||||
- [x] Карточка `Стоимость портфеля за 6 месяцев`: зелёный градиентный фон и зелёная рамка.
|
|
||||||
- [x] График стоимости: 6 месячных значений, 6 подписей месяцев, плавная линия без visible markers.
|
|
||||||
- [x] График стоимости: первая точка у левого края, последняя у правого края.
|
|
||||||
- [x] Loading графика: chart-like indicator (meta skel + dashed guide + shimmer area).
|
|
||||||
- [x] Заголовок/yield: skeleton-полосы при загрузке портфеля (label, value, daily).
|
|
||||||
- [x] Analytics loading: 2 summary карточки + 6 detail карточек со skel барами.
|
|
||||||
- [x] Events loading: skeleton-table со структурой строк дата/инструмент/тип/сумма.
|
|
||||||
- [x] Analytics summary: только `Стоимость портфеля` и `Всего доходов`.
|
|
||||||
- [x] Analytics detail grid: `Пополнения`, `Выводы`, `Дивиденды`, `Купоны`, `Комиссия`,
|
|
||||||
`Уплаченные налоги`.
|
|
||||||
- [x] Analytics overview не показывает `Нетто` и `Всего получено`.
|
|
||||||
- [x] `Комиссия` и `Уплаченные налоги` отображаются как отрицательные UI-суммы.
|
|
||||||
- [x] Карточка структуры называется `Структура`.
|
|
||||||
- [x] Карточка структуры не показывает subtitle `Структура портфеля`.
|
|
||||||
- [x] Карточка структуры показывает итоговую стоимость под заголовком.
|
|
||||||
- [x] Карточка структуры показывает бары `Акции`, `Облигации`, `Деньги`.
|
|
||||||
- [x] Карточка последних событий называется `Последние события`.
|
|
||||||
- [x] Последние события используют executed operations, а не calendar events.
|
|
||||||
- [x] Последние события отсортированы новые → старые.
|
|
||||||
- [x] Последние события не показывают toolbar, count badge, footer summary и колонку `Статус`.
|
|
||||||
- [x] Инструмент в последних событиях: название сверху жирным, ticker/ISIN снизу серым.
|
|
||||||
- [x] Налоги/комиссии/списания отображаются красным и с корректным бейджем типа.
|
|
||||||
- [x] На mobile нет page-level horizontal overflow.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- [x] Backend targeted:
|
|
||||||
`npm run test -w apps/backend -- src/modules/tbank/services/broker-analytics.service.spec.ts src/modules/tbank/tbank.controller.spec.ts`
|
|
||||||
- [x] Frontend targeted:
|
|
||||||
`npm run test -w apps/frontend -- --run src/widgets/broker-dashboard`
|
|
||||||
- [x] Full frontend:
|
|
||||||
`npm run test:frontend`
|
|
||||||
- [x] Frontend lint:
|
|
||||||
`npm run lint -w apps/frontend`
|
|
||||||
- [x] Frontend build:
|
|
||||||
`npm run build:frontend`
|
|
||||||
- [x] Проверить OpenAPI/codegen после backend изменений.
|
|
||||||
|
|
||||||
## Visual QA
|
|
||||||
|
|
||||||
- [ ] Проверить `/broker/:accountId` на desktop против
|
|
||||||
`docs/research/frontend-overview-redesign/example.html` (ручная проверка)
|
|
||||||
- [ ] Проверить `/broker/:accountId` на viewport `390x844` (ручная проверка)
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
|
|
||||||
- [x] Все acceptance criteria из `spec.md` выполнены.
|
|
||||||
- [x] Backend tests проходят (34 files, 161 passed).
|
|
||||||
- [x] Frontend targeted tests проходят.
|
|
||||||
- [x] `npm run test:frontend` проходит (32 files, 168 passed).
|
|
||||||
- [x] `npm run lint -w apps/frontend` проходит.
|
|
||||||
- [x] `npm run build:frontend` проходит.
|
|
||||||
- [x] Generated OpenAPI types обновлены через codegen.
|
|
||||||
- [ ] Visual QA desktop/mobile выполнена (ручная проверка).
|
|
||||||
- [x] Существующие detailed вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика`
|
|
||||||
остаются доступны.
|
|
||||||
- [x] `tasks.md` обновлён по факту выполнения.
|
|
||||||
|
|
||||||
## Дополнительные улучшения (после основной реализации)
|
|
||||||
|
|
||||||
- [x] Бейдж событий перевести с MUI Chip на кастомный Badge с цветами эталона.
|
|
||||||
- [x] Пагинация событий: wire `useState` + handlers, API limit поднят до 100.
|
|
||||||
- [x] Подписи месяцев на графике: первая left-aligned, последняя right-aligned.
|
|
||||||
- [x] `BrokerAnalyticsService` переписан на прямой вызов T-Bank API:
|
|
||||||
- убран Prisma для analytics;
|
|
||||||
- `GetOperationsByCursor` с пагинацией за всю историю (без ограничения по `from`);
|
|
||||||
- `GetPortfolio.expectedYield` используется как `totalReturnPercent`.
|
|
||||||
- [x] Структурные skeletons для chart/analytics/title-yield совпадают с эталоном.
|
|
||||||
@ -1,304 +0,0 @@
|
|||||||
# План реализации редизайна обзора брокерского счёта
|
|
||||||
|
|
||||||
> **Для агентных исполнителей:** ОБЯЗАТЕЛЬНЫЙ SUB-SKILL: использовать `superpowers:subagent-driven-development` (предпочтительно) или `superpowers:executing-plans` для пошагового выполнения. Шаги ведутся чекбоксами `- [ ]`.
|
|
||||||
|
|
||||||
**Цель:** превратить `/broker/:accountId` в компактный инвестиционный дашборд на текущей светлой теме без изменения backend-контрактов и URL-структуры.
|
|
||||||
|
|
||||||
**Архитектура:** маршрут и FSD-границы остаются прежними. Композиция собирается в `widgets/broker-dashboard`, данные продолжают приходить из существующих `entities/*` hooks. Навигация счёта остаётся в `BrokerAccountLayout`, а dashboard использует только локальные presentation/helpers без выноса брокерской логики в design system.
|
|
||||||
|
|
||||||
**Технологии:** React 18, TanStack Router, TanStack Query, MUI через `@moex-vibe/design-system`, MUI X DateCalendar community (`@mui/x-date-pickers`) с Day.js, Vitest, Testing Library.
|
|
||||||
|
|
||||||
**HTML parity update:** согласованный визуальный эталон находится в `docs/research/2026-06-27-broker-account-redesign.html`.
|
|
||||||
Следующая итерация переносит его детали в реальную страницу без изменения backend-контрактов.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Область реализации
|
|
||||||
|
|
||||||
### Файлы
|
|
||||||
|
|
||||||
- Modify: `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx` — горизонтальные вкладки над контентом обзора и подробных разделов.
|
|
||||||
- Modify: `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx` — точка входа обзора через dashboard.
|
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/index.ts` — публичный API dashboard-виджета.
|
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` — верхнеуровневая композиция и управление filter state.
|
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx` — hero KPI с fallback-значениями и выравниванием `Metric`.
|
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx` — локальный паттерн карточки.
|
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` — карточка событий.
|
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx` — переиспользуемое управление периодом с draft/apply поведением.
|
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx` — карточка доходов.
|
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx` — карточка аналитики доходности.
|
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx` — карточка аллокации с горизонтальными барами.
|
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx` — skeleton-форма dashboard.
|
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` — компонентные тесты композиции и фильтров.
|
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts` — чистые helpers доходных операций.
|
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` — unit-тесты income helpers.
|
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` — пресеты дат, validate и mapping income types.
|
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts` — локальные formatter/helpers для fallback и event labels.
|
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` — локальные helpers визуальной семантики dashboard: tone сумм, tone типов, отображение инструмента, символ валюты.
|
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts` — unit-тесты визуальных helpers.
|
|
||||||
- Modify: `apps/frontend/package.json` — добавить community-пакет `@mui/x-date-pickers`, если зависимость ещё не подключена; `@mui/x-date-pickers-pro` не добавлять.
|
|
||||||
- Modify: `docs/features/broker-dashboard-redesign/tasks.md` — фиксация статусов выполнения.
|
|
||||||
|
|
||||||
### Источники данных
|
|
||||||
|
|
||||||
- Портфель: `useBrokerAccountContext().portfolio`.
|
|
||||||
- События: `useBrokerEvents(accountId, { from, to, types })`.
|
|
||||||
- Операции: `useBrokerOperations(accountId, { from, to, operationTypes, cursor, limit: 10 })`.
|
|
||||||
- Аналитика: `useBrokerAnalytics(accountId)`.
|
|
||||||
- Аллокация: `buildBrokerAllocation(portfolio)`.
|
|
||||||
|
|
||||||
## Технические решения
|
|
||||||
|
|
||||||
### 1. Композиция страницы
|
|
||||||
|
|
||||||
- Dashboard на desktop и mobile строится в одну колонку: `Hero`, `События`, `Доходы`, `Аналитика доходности`, `Аллокация`.
|
|
||||||
- Двухколоночный layout из прототипа не переносится.
|
|
||||||
- Навигация счёта находится над контентом в `BrokerAccountLayout` и не дублируется внутри dashboard.
|
|
||||||
|
|
||||||
### 2. Hero KPI
|
|
||||||
|
|
||||||
- Hero показывает название счёта, стоимость портфеля, доходность, дневное изменение и всего полученных доходов.
|
|
||||||
- Приоритет доходности: `analytics.totalReturnPercent`, затем `portfolio.yields.expectedPercent`, иначе `—`.
|
|
||||||
- Дневное изменение берётся из `portfolio.yields.daily` и `portfolio.yields.dailyPercent`, при недоступности показывается `—`.
|
|
||||||
- Все `Metric` должны иметь одинаковую высоту; supporting text не должен ломать вертикальный ритм.
|
|
||||||
|
|
||||||
### 3. События
|
|
||||||
|
|
||||||
- Типы событий переключаются chip-фильтрами с немедленным применением.
|
|
||||||
- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня + 7 дней`, чтобы обзор сразу показывал
|
|
||||||
ближайшие будущие события.
|
|
||||||
- Диапазон дат редактируется отдельно от применённого состояния: draft state меняется локально, запрос уходит только по действию применения периода.
|
|
||||||
- В шапке управления периодом не используется слово `Фильтр`; роль управления считывается через иконку календаря, применённый диапазон, раскрытие панели и пресеты.
|
|
||||||
- Native `<input type="date">` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`.
|
|
||||||
- `DateRangePicker` и `@mui/x-date-pickers-pro` не используются; выбор начала/конца периода реализуется локальной логикой dashboard.
|
|
||||||
- При пустом выборе типов запрос отключается, карточка показывает валидационное сообщение.
|
|
||||||
- Пагинация локальная, по 10 событий на страницу, сбрасывается при смене применённых фильтров.
|
|
||||||
- При загрузке карточка показывает skeleton таблицы событий с колонками дата, инструмент, тип, сумма, статус.
|
|
||||||
- В toolbar карточки используются count badge, label `Тип`, кнопка `Обновить` и footer-summary по паттерну HTML-эталона.
|
|
||||||
|
|
||||||
### 4. Доходы
|
|
||||||
|
|
||||||
- Доходы строятся на существующем endpoint операций только для `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`.
|
|
||||||
- Типы доходов переключаются chip-фильтрами с немедленным применением.
|
|
||||||
- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня`, чтобы не загружать большой объём операций на первом открытии.
|
|
||||||
- Диапазон дат использует тот же draft/apply паттерн, что и события.
|
|
||||||
- В шапке управления периодом не используется слово `Фильтр`; действие применения периода не называется `Показать`.
|
|
||||||
- Native `<input type="date">` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`.
|
|
||||||
- `DateRangePicker` и `@mui/x-date-pickers-pro` не используются; выбор начала/конца периода реализуется локальной логикой dashboard.
|
|
||||||
- Пагинация cursor-based, размер страницы 10, сбрасывается при смене применённых фильтров.
|
|
||||||
- При загрузке карточка показывает skeleton таблицы доходов с колонками дата, инструмент, тип, сумма.
|
|
||||||
|
|
||||||
### 5. Аналитика и аллокация
|
|
||||||
|
|
||||||
- Карточка аналитики использует существующий analytics endpoint и показывает спокойное empty state при отсутствии данных.
|
|
||||||
- Карточка аллокации не использует donut chart. Она строит список горизонтальных bar rows по `buildBrokerAllocation`.
|
|
||||||
- Отрицательные значения показываются текстом без полосы.
|
|
||||||
|
|
||||||
### 6. Ошибки и пустые состояния
|
|
||||||
|
|
||||||
- Ошибка `portfolio` роняет весь обзор.
|
|
||||||
- Ошибки `events`, `income`, `analytics` локальны соответствующим карточкам.
|
|
||||||
- Пустые данные показываются отдельными сообщениями, а не нулевыми значениями.
|
|
||||||
|
|
||||||
### 7. HTML parity visual layer
|
|
||||||
|
|
||||||
- Реальная страница `/broker/:accountId` должна визуально соответствовать `docs/research/2026-06-27-broker-account-redesign.html`,
|
|
||||||
но использовать существующие React-компоненты и FSD-границы.
|
|
||||||
- Не менять backend и OpenAPI: блок `Доходы` остаётся на текущем endpoint операций и текущем наборе
|
|
||||||
income-типов. Отрицательный tone должен поддерживаться для строк, которые уже отображаются или будут
|
|
||||||
отображаться без расширения контракта.
|
|
||||||
- Ввести локальные helpers в `widgets/broker-dashboard/lib/dashboardVisual.ts`:
|
|
||||||
`moneyTone(value, source?) -> 'positive' | 'negative' | 'planned' | 'neutral'`,
|
|
||||||
`eventTypeTone(type)`, `incomeTypeTone(typeLabel)`, `formatDashboardCurrency(moneyOrValue)`,
|
|
||||||
`instrumentDisplay({ ticker, name, description })`.
|
|
||||||
- `formatDashboardCurrency` для RUB должен выводить `₽`. Для неизвестных валют использовать код валюты.
|
|
||||||
- `instrumentDisplay` должен возвращать основную строку и опциональную подпись: для событий приоритет
|
|
||||||
`ticker/isin` как main и `name` как subtitle; для операций приоритет `ticker` как main и
|
|
||||||
`name/description` как subtitle. Если ticker отсутствует, main берётся из name/description, subtitle не
|
|
||||||
дублируется.
|
|
||||||
- `BrokerDashboardCard` должен поддержать компактный заголовок карточки уровня HTML-прототипа, не
|
|
||||||
используя крупный `Heading size="title"`.
|
|
||||||
- `BrokerDashboardDateFilter` должен использовать иконку раскрытия вместо текстового символа и сохранять
|
|
||||||
единый toolbar-паттерн для `События` и `Доходы`.
|
|
||||||
- Для `События` period presets должны поддерживать будущую часть диапазона, а не обрезаться текущим днём.
|
|
||||||
- Таблицы `События` и `Доходы` должны иметь `thead`, type badges, двухстрочный инструмент при наличии
|
|
||||||
названия и semantic amount colors.
|
|
||||||
- `BrokerDashboardAnalyticsCard` должен окрашивать KPI-карточки по смыслу и показывать RUB через `₽`.
|
|
||||||
- Skeleton таблиц событий и доходов должен использовать один компонент/паттерн и различаться только
|
|
||||||
числом колонок.
|
|
||||||
|
|
||||||
## Задачи
|
|
||||||
|
|
||||||
### Задача 1: Базовые helpers и локальные dashboard-patterns
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx`
|
|
||||||
|
|
||||||
- [ ] Убедиться, что income helpers покрывают только допустимые типы доходов и умеют считать итог отображаемых строк.
|
|
||||||
- [ ] Убедиться, что filter helpers содержат default state, date presets, validate и mapping income type -> operation types.
|
|
||||||
- [ ] Убедиться, что formatters покрывают fallback-значения, label типов событий и label статусов.
|
|
||||||
- [ ] Использовать локальный `BrokerDashboardCard` как основной контейнер карточек; design system расширять только если без этого нельзя реализовать требования спецификации.
|
|
||||||
|
|
||||||
### Задача 2: Hero и layout overview
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
|
||||||
- `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx`
|
|
||||||
|
|
||||||
- [ ] Собрать обзор через `BrokerDashboard` и `BrokerDashboardSkeleton`.
|
|
||||||
- [ ] Оставить навигацию счёта в `BrokerAccountLayout` как горизонтальные вкладки над контентом.
|
|
||||||
- [ ] Исправить hero так, чтобы все `Metric` были одной высоты и поддерживающий текст не поднимал одну ячейку выше остальных.
|
|
||||||
- [ ] Проверить, что dashboard остаётся одноколоночным и на desktop, и на mobile.
|
|
||||||
|
|
||||||
### Задача 3: Карточка событий
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`
|
|
||||||
- `apps/frontend/package.json`
|
|
||||||
|
|
||||||
- [ ] Добавить или подтвердить зависимость community-пакета `@mui/x-date-pickers` и использовать Day.js adapter.
|
|
||||||
- [ ] Не добавлять `@mui/x-date-pickers-pro` и не использовать `DateRangePicker`.
|
|
||||||
- [ ] Обновить `BrokerDashboardDateFilter`: убрать слово `Фильтр` из пользовательского текста, заменить `Показать` на понятное действие применения периода и визуально собрать chips, диапазон, сброс и применение в аккуратную шапку.
|
|
||||||
- [ ] Заменить native date inputs на одно визуальное поле периода, которое открывает MUI `Popover` с community `DateCalendar`.
|
|
||||||
- [ ] Реализовать локальную логику выбора диапазона: первый клик задаёт начало, второй — конец; если конец раньше начала, диапазон пересобирается от выбранной даты.
|
|
||||||
- [ ] Настроить default range событий на `сегодня - 7 дней` / `сегодня + 7 дней`.
|
|
||||||
- [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по действию применения периода.
|
|
||||||
- [ ] Сохранять локальную пагинацию по 10 событий и сбрасывать её при смене применённых фильтров.
|
|
||||||
- [ ] Заменить текстовую загрузку событий на skeleton таблицы.
|
|
||||||
- [ ] Оставить локальные error/empty states внутри карточки.
|
|
||||||
|
|
||||||
### Задача 4: Карточка доходов
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`
|
|
||||||
|
|
||||||
- [ ] Подключить тот же `BrokerDashboardDateFilter` для доходов с draft/applied state.
|
|
||||||
- [ ] Настроить default range доходов на `сегодня - 7 дней` / `сегодня` вместо периода с начала года.
|
|
||||||
- [ ] Оставить chip-фильтры типов доходов с немедленным применением.
|
|
||||||
- [ ] Сохранить cursor pagination по 10 операций и сбрасывать её при смене применённых фильтров.
|
|
||||||
- [ ] Заменить текстовую загрузку доходов на skeleton таблицы.
|
|
||||||
- [ ] Если endpoint операций даёт недостаточно релевантных строк для dashboard, зафиксировать ограничение в заметках по реализации, а не расширять backend в рамках этой фичи.
|
|
||||||
|
|
||||||
### Задача 5: Карточки аналитики и аллокации
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx`
|
|
||||||
|
|
||||||
- [ ] Довести карточку аналитики до соответствия спецификации по полям и состояниям.
|
|
||||||
- [ ] Заменить donut chart на горизонтальные бары, построенные из `buildBrokerAllocation`.
|
|
||||||
- [ ] Отрицательные значения аллокации выводить отдельно текстом без bar.
|
|
||||||
|
|
||||||
### Задача 6: Тесты и верификация
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`
|
|
||||||
- `docs/features/broker-dashboard-redesign/tasks.md`
|
|
||||||
|
|
||||||
- [ ] Обновить компонентные тесты dashboard так, чтобы они покрывали текущую композицию, фильтры и основные empty/error states.
|
|
||||||
- [ ] Обновить тесты, которые ищут `Фильтр дат` или `Показать`, под новые тексты и accessible labels единого поля периода.
|
|
||||||
- [ ] Добавить/обновить тесты default weekly range для событий и доходов.
|
|
||||||
- [ ] Добавить/обновить тесты skeleton-таблиц для loading state событий и доходов.
|
|
||||||
- [ ] Прогнать `rtk npm run test:frontend -- --run src/widgets/broker-dashboard`.
|
|
||||||
- [ ] Прогнать `rtk npm run test:frontend`.
|
|
||||||
- [ ] Прогнать `rtk npm run test:design-system && rtk npm run lint -w apps/frontend && rtk npm run build:frontend`.
|
|
||||||
- [ ] Проверить вручную desktop layout `/broker/2084014113`.
|
|
||||||
- [ ] Проверить вручную mobile layout `/broker/2084014113` на viewport `390x844`.
|
|
||||||
- [ ] После завершения обновить `docs/features/broker-dashboard-redesign/tasks.md` и выполнить `graphify update .`.
|
|
||||||
|
|
||||||
### Задача 7: Visual helpers для HTML parity
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts`
|
|
||||||
|
|
||||||
- [ ] Добавить `moneyTone`, который возвращает `negative` для отрицательных значений, `positive` для
|
|
||||||
положительных фактических значений, `planned` для прогнозных/нефактических значений и `neutral` для
|
|
||||||
нуля/недоступного значения.
|
|
||||||
- [ ] Добавить `formatDashboardCurrency`, который для `RUB` выводит `₽`, а для неизвестной валюты
|
|
||||||
оставляет код валюты.
|
|
||||||
- [ ] Добавить `instrumentDisplay` с приоритетами main/subtitle из технического решения 7.
|
|
||||||
- [ ] Добавить type tone helpers для event types и income labels.
|
|
||||||
- [ ] Расширить `DashboardIncomeRow`: хранить `instrumentMain` и `instrumentSubtitle`, сохранив
|
|
||||||
совместимость через существующий `instrument` только если это нужно текущим тестам.
|
|
||||||
- [ ] Покрыть helpers unit-тестами: RUB symbol, unknown currency fallback, negative/positive/planned
|
|
||||||
tones, event/income type tones, отсутствие дублирования subtitle.
|
|
||||||
|
|
||||||
### Задача 8: Hero, карточка и toolbar parity
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`
|
|
||||||
|
|
||||||
- [ ] Применить semantic color к hero: отрицательная доходность красная, положительная зелёная,
|
|
||||||
дневное изменение окрашивается по знаку, `Всего доходов` зелёный при значении больше нуля.
|
|
||||||
- [ ] Сделать заголовки dashboard-card компактными, соответствующими HTML-прототипу.
|
|
||||||
- [ ] Собрать filters area карточек в единый toolbar: слева типы, справа поле периода и действие.
|
|
||||||
- [ ] Заменить текстовую стрелку раскрытия периода на иконку: использовать уже подключённые
|
|
||||||
`CalendarTodayRounded` и `ExpandMoreRounded` из `@mui/icons-material`, без добавления новой icon
|
|
||||||
dependency.
|
|
||||||
- [ ] Обновить component tests: проверять отсутствие текста `RUB` для RUB-значений, отсутствие символа
|
|
||||||
`v` в period button и наличие accessible name у управления периодом.
|
|
||||||
|
|
||||||
### Задача 9: Таблицы событий и доходов как в HTML
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableSkeleton.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`
|
|
||||||
|
|
||||||
- [ ] Добавить `thead` в обе dashboard-таблицы с колонками из спецификации.
|
|
||||||
- [ ] В колонке инструмента выводить main и subtitle из `instrumentDisplay`.
|
|
||||||
- [ ] Заменить текстовые типы на компактные бейджи с tone из visual helpers.
|
|
||||||
- [ ] Окрашивать суммы через `moneyTone`: поступления зелёным, списания красным, прогноз/ожидание серым.
|
|
||||||
- [ ] Окрашивать статусные бейджи: `Поступило` зелёный, прогноз/ожидание серый.
|
|
||||||
- [ ] Сохранить горизонтальный scroll только внутри таблицы на mobile, без общего page overflow.
|
|
||||||
- [ ] Обновить skeleton так, чтобы `События` и `Доходы` использовали один визуальный паттерн строк.
|
|
||||||
- [ ] Обновить tests на наличие type badges, subtitle инструмента, semantic amount classes и skeleton.
|
|
||||||
|
|
||||||
### Задача 10: Analytics parity и визуальная проверка
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx`
|
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`
|
|
||||||
- `docs/features/broker-dashboard-redesign/tasks.md`
|
|
||||||
|
|
||||||
- [ ] Отображать RUB как `₽` во всех analytics KPI.
|
|
||||||
- [ ] Окрашивать analytics cards: positive для пополнений/дивидендов/купонов/всего получено,
|
|
||||||
negative для выводов и отрицательного нетто, neutral для нулевых/недоступных значений.
|
|
||||||
- [ ] Обновить component tests для analytics: `₽` вместо `RUB`, positive/negative tone cards.
|
|
||||||
- [ ] Прогнать `rtk npm run test:frontend -- --run src/widgets/broker-dashboard`.
|
|
||||||
- [ ] Прогнать `rtk npm run test:frontend`.
|
|
||||||
- [ ] Прогнать `rtk npm run lint -w apps/frontend && rtk npm run build:frontend`.
|
|
||||||
- [ ] Проверить `/broker/2084014113` вручную на desktop и mobile `390x844` против
|
|
||||||
`docs/research/2026-06-27-broker-account-redesign.html`.
|
|
||||||
- [ ] После проверки обновить `docs/features/broker-dashboard-redesign/tasks.md`.
|
|
||||||
|
|
||||||
## Проверка покрытия спецификации
|
|
||||||
|
|
||||||
- Общая композиция и горизонтальная навигация: задачи 2 и 6.
|
|
||||||
- Hero KPI и equal-height `Metric`: задача 2.
|
|
||||||
- События с chip filters, date filters и локальной пагинацией: задача 3.
|
|
||||||
- Доходы с chip filters, date filters и cursor pagination: задача 4.
|
|
||||||
- Community DateCalendar range control, weekly default range и skeleton loading tables: задачи 3, 4 и 6.
|
|
||||||
- Аналитика и аллокация с горизонтальными барами: задача 5.
|
|
||||||
- Ошибки, empty states, тесты и ручная верификация: задача 6.
|
|
||||||
- HTML parity: visual helpers — задача 7, hero/card/toolbar — задача 8, dashboard tables — задача 9,
|
|
||||||
analytics — задача 10.
|
|
||||||
@ -1,292 +0,0 @@
|
|||||||
# Редизайн обзора брокерского счёта в инвестиционный дашборд
|
|
||||||
|
|
||||||
Дата: 2026-06-26 (обновлено 2026-06-27, HTML parity)
|
|
||||||
Статус: согласовано к планированию
|
|
||||||
Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md)
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
|
|
||||||
Текущий маршрут `/broker/:accountId` показывает корректный overview выбранного брокерского счёта, но
|
|
||||||
визуально остаётся вертикальным набором отдельных блоков: сводка, аллокация, карточки активов,
|
|
||||||
ближайшие события и последние операции. Пользователь подготовил прототип `temp.html`, где тот же домен
|
|
||||||
представлен как более плотный инвестиционный дашборд с hero KPI, компактными таблицами и аналитическими
|
|
||||||
карточками.
|
|
||||||
|
|
||||||
После обсуждения выбран вариант A: страница `/broker/:accountId` должна стать единым дашбордом,
|
|
||||||
используя структуру и плотность прототипа, но сохраняя текущую светлую тему и компоненты
|
|
||||||
`@moex-vibe/design-system`. Тёмная тема из прототипа не переносится в первую версию. При этом
|
|
||||||
двухколоночная desktop-композиция прототипа сознательно не переносится: и на desktop, и на mobile блоки
|
|
||||||
dashboard идут по одному на строке в фиксированном порядке.
|
|
||||||
|
|
||||||
После интерактивной дизайн-итерации согласован статический эталон
|
|
||||||
`docs/research/2026-06-27-broker-account-redesign.html`. Реальная React-страница должна визуально соответствовать этому HTML:
|
|
||||||
компактные заголовки карточек, единая шапка таблиц, бейджи типов, подписи инструментов, семантические
|
|
||||||
цвета денежных значений и единый skeleton таблиц.
|
|
||||||
|
|
||||||
## Цель
|
|
||||||
|
|
||||||
Сделать overview брокерского счёта быстрым обзором состояния портфеля, будущих/прошедших событий,
|
|
||||||
полученных доходов, аналитики доходности и аллокации без перехода по вкладкам.
|
|
||||||
|
|
||||||
## Пользовательский результат
|
|
||||||
|
|
||||||
Пользователь может на `/broker/:accountId`:
|
|
||||||
|
|
||||||
- сразу увидеть стоимость портфеля, доходность и сумму полученных доходов;
|
|
||||||
- увидеть ближайшие события по счёту в компактной таблице;
|
|
||||||
- увидеть последние доходные операции по дивидендам и купонам и итог по ним;
|
|
||||||
- оценить вложения, полученные выплаты и доходность по существующей аналитике;
|
|
||||||
- увидеть структуру портфеля через горизонтальные полосы аллокации и подписи к ним;
|
|
||||||
- перейти в существующие подробные вкладки `Акции`, `Облигации`, `Операции`, `События` и `Аналитика`
|
|
||||||
для drill-down сценариев.
|
|
||||||
|
|
||||||
## Область изменений
|
|
||||||
|
|
||||||
Фича относится только к frontend-маршруту `/broker/:accountId` и связанным frontend-компонентам
|
|
||||||
брокерского overview.
|
|
||||||
|
|
||||||
В область входят:
|
|
||||||
|
|
||||||
- новая dashboard-композиция для `BrokerAccountOverviewPage`;
|
|
||||||
- переиспользуемые frontend-паттерны для dashboard card, KPI, compact table и filter chips;
|
|
||||||
- адаптация существующих брокерских widgets под новую компоновку, если это нужно для читаемости;
|
|
||||||
- точечные доработки `@moex-vibe/design-system`, если существующие компоненты блокируют корректное
|
|
||||||
использование текущей светлой DS-темы;
|
|
||||||
- тесты новой композиции и ключевых представлений.
|
|
||||||
|
|
||||||
## Требования
|
|
||||||
|
|
||||||
### 1. Общая композиция
|
|
||||||
|
|
||||||
- `/broker/:accountId` остаётся overview выбранного брокерского счёта.
|
|
||||||
- Overview визуально становится dashboard-страницей, а не вертикальным списком независимых секций.
|
|
||||||
- Существующая навигация счёта отображается горизонтальными вкладками над контентом, чтобы не занимать
|
|
||||||
левую колонку и оставить больше пространства для таблиц dashboard.
|
|
||||||
- На desktop и mobile блоки dashboard отображаются по одному блоку на строке: hero KPI, `События`,
|
|
||||||
`Доходы`, `Аналитика доходности`, `Аллокация`.
|
|
||||||
- Существующая навигация счёта сохраняет ссылки на `Обзор`, `Акции`, `Облигации`, `Операции`,
|
|
||||||
`События`, `Аналитика`.
|
|
||||||
- На мобильном viewport сохраняется тот же порядок блоков в одну колонку.
|
|
||||||
|
|
||||||
### 2. Визуальный стиль
|
|
||||||
|
|
||||||
- Используется текущая светлая DS-тема MoexVibe.
|
|
||||||
- Не добавляется dark mode и не переносится тёмная палитра `temp.html`.
|
|
||||||
- Визуальная плотность, структура карточек, KPI-иерархия и компактность таблиц ориентируются на
|
|
||||||
`docs/research/2026-06-27-broker-account-redesign.html`.
|
|
||||||
- Дизайн использует компоненты и токены `@moex-vibe/design-system` там, где они применимы.
|
|
||||||
- Если DS-компонент слишком ограничен, допускается точечно расширить DS API или создать локальный
|
|
||||||
dashboard-pattern в frontend, но не добавлять доменную брокерскую логику в DS.
|
|
||||||
- Заголовки dashboard-карточек не должны использовать page-level размер `Heading size="title"`;
|
|
||||||
визуально они должны соответствовать компактному card heading из HTML-прототипа.
|
|
||||||
- В карточках `События` и `Доходы` фильтры должны быть собраны в единый toolbar: группа типов, единое
|
|
||||||
поле периода с иконкой календаря и chevron-иконкой, действие обновления/применения периода.
|
|
||||||
- В поле периода не используется текстовый символ `v`; раскрытие обозначается иконкой.
|
|
||||||
- Валюта в dashboard отображается символом `₽`, а не строкой `RUB`, кроме случаев, где backend вернул
|
|
||||||
другую валюту и formatter проекта не знает её символ.
|
|
||||||
|
|
||||||
### 3. Hero KPI
|
|
||||||
|
|
||||||
Hero показывает:
|
|
||||||
|
|
||||||
- название счёта или fallback `Брокерский счёт`;
|
|
||||||
- стоимость портфеля из `portfolio.totals.portfolio`;
|
|
||||||
- доходность из существующих данных: приоритет `broker analytics.totalReturnPercent`, если загружена,
|
|
||||||
иначе `portfolio.yields.expectedPercent`, если доступна;
|
|
||||||
- дневное изменение из `portfolio.yields.daily` и `portfolio.yields.dailyPercent`, если доступно;
|
|
||||||
- всего полученных доходов из `broker analytics.totalReceived`, если аналитика загружена;
|
|
||||||
- спокойный fallback `—` для недоступных значений.
|
|
||||||
- Все Metric-блоки имеют одинаковую высоту в grid-ряду, даже если у некоторых есть supportingText. Поддерживающий текст остаётся внутри Metric, но все Metrics растягиваются на полную высоту grid-ячейки с выравниванием от верхнего края.
|
|
||||||
- Доходность в hero имеет цветовую семантику: положительная — зелёная, отрицательная — красная,
|
|
||||||
недоступная/нулевая — нейтральная.
|
|
||||||
- Дневное изменение в supporting text hero тоже окрашивается по знаку значения.
|
|
||||||
- `Всего доходов` окрашивается как положительный финансовый показатель, если значение больше нуля.
|
|
||||||
|
|
||||||
### 4. Блок `События`
|
|
||||||
|
|
||||||
- Блок использует существующий источник `useBrokerEvents(accountId, query)`.
|
|
||||||
- По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы
|
|
||||||
`dividend,coupon,maturity,offer`, как в существующей вкладке событий.
|
|
||||||
- Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`.
|
|
||||||
- Пользователь может выбрать несколько типов событий.
|
|
||||||
- Если пользователь снимает все типы событий, запрос не выполняется, а блок показывает
|
|
||||||
валидационное сообщение.
|
|
||||||
- Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую
|
|
||||||
страницу.
|
|
||||||
- Блок содержит фильтр периода `from` / `to`.
|
|
||||||
- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня + 7 дней`, чтобы обзор
|
|
||||||
сразу показывал ближайшие будущие события.
|
|
||||||
- Изменение черновых фильтров не запускает запрос до применения периода пользователем.
|
|
||||||
- В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как
|
|
||||||
управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия.
|
|
||||||
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё`, действие `Сбросить` и
|
|
||||||
основное действие применения периода с более понятным текстом, чем `Показать`.
|
|
||||||
- Период отображается как одно визуальное поле/кнопка с диапазоном, например `19 июн – 26 июн`.
|
|
||||||
- По клику на поле периода открывается popover с community-компонентом MUI `DateCalendar` из
|
|
||||||
`@mui/x-date-pickers` и ручной логикой выбора начала/конца периода.
|
|
||||||
- `DateRangePicker` и пакет `@mui/x-date-pickers-pro` не используются.
|
|
||||||
- Native `<input type="date">` не используется.
|
|
||||||
- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся
|
|
||||||
обязанностью подробной вкладки `События`.
|
|
||||||
- В dashboard отображается не более 10 событий на странице.
|
|
||||||
- Если в выбранном диапазоне больше 10 событий, блок показывает локальную пагинацию по страницам.
|
|
||||||
- Смена применённых фильтров возвращает пагинацию блока на первую страницу.
|
|
||||||
- Таблица показывает дату, инструмент, тип, сумму и статус.
|
|
||||||
- Таблица содержит компактную строку заголовков колонок.
|
|
||||||
- В колонке `Инструмент` показывается тикер/ISIN и дополнительная строка с названием инструмента, если
|
|
||||||
оно доступно из `event.name`; если названия нет, дополнительная строка не занимает место.
|
|
||||||
- В колонке `Тип` значения отображаются небольшими бейджами с разными тонами для купона, дивиденда,
|
|
||||||
погашения и оферты.
|
|
||||||
- Сумма для `actual` берётся из `actualAmount`, сумма для `forecast` берётся из `estimatedAmount`.
|
|
||||||
- Фактические поступления визуально отмечаются как `Поступило`.
|
|
||||||
- Прогнозные суммы помечаются как оценочные.
|
|
||||||
- Суммы окрашиваются по смыслу: фактическое положительное поступление зелёным, отрицательное списание
|
|
||||||
красным, прогноз/план серым. Цвет не является единственным носителем смысла: прогнозные строки также
|
|
||||||
имеют статусный текст.
|
|
||||||
- Статус отображается компактным бейджем. `Поступило` использует зелёный тон, прогноз/ожидание —
|
|
||||||
нейтральный серый тон.
|
|
||||||
- Блок содержит ссылку на подробную вкладку `/broker/:accountId/events`.
|
|
||||||
- Ошибка загрузки событий не ломает остальной dashboard.
|
|
||||||
|
|
||||||
### 5. Блок `Доходы`
|
|
||||||
|
|
||||||
- Блок показывает последние доходные операции по дивидендам и купонам из существующего endpoint
|
|
||||||
операций.
|
|
||||||
- В первую версию входят операции с типами дивидендов и купонов, которые уже используются в backend
|
|
||||||
analytics: `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`.
|
|
||||||
- Блок содержит кликабельные chip-фильтры типов доходов: `Дивиденды`, `Купоны`.
|
|
||||||
- Пользователь может выбрать один или оба типа доходов.
|
|
||||||
- Если пользователь снимает все типы доходов, запрос не выполняется, а блок показывает
|
|
||||||
валидационное сообщение.
|
|
||||||
- Изменение chip-фильтров типов доходов применяется сразу и сбрасывает cursor-пагинацию.
|
|
||||||
- Блок содержит фильтр периода `from` / `to`.
|
|
||||||
- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня`, чтобы dashboard не загружал
|
|
||||||
слишком много операций при первом открытии.
|
|
||||||
- Изменение черновых фильтров не запускает запрос до применения периода пользователем.
|
|
||||||
- В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как
|
|
||||||
управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия.
|
|
||||||
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё`, действие `Сбросить` и
|
|
||||||
основное действие применения периода с более понятным текстом, чем `Показать`.
|
|
||||||
- Период отображается как одно визуальное поле/кнопка с диапазоном, например `19 июн – 26 июн`.
|
|
||||||
- По клику на поле периода открывается popover с community-компонентом MUI `DateCalendar` из
|
|
||||||
`@mui/x-date-pickers` и ручной логикой выбора начала/конца периода.
|
|
||||||
- `DateRangePicker` и пакет `@mui/x-date-pickers-pro` не используются.
|
|
||||||
- Native `<input type="date">` не используется.
|
|
||||||
- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся
|
|
||||||
обязанностью подробных разделов.
|
|
||||||
- Для блока используется cursor-пагинация existing operations endpoint с размером страницы 10.
|
|
||||||
- Смена применённых фильтров сбрасывает cursor-пагинацию блока на первую страницу.
|
|
||||||
- Таблица показывает дату, инструмент, тип и сумму.
|
|
||||||
- Таблица содержит компактную строку заголовков колонок.
|
|
||||||
- В колонке `Инструмент` показывается тикер и дополнительная строка с названием операции/инструмента,
|
|
||||||
если оно доступно из `operation.name` или `operation.description`.
|
|
||||||
- В колонке `Тип` значения отображаются небольшими бейджами.
|
|
||||||
- Суммы окрашиваются по знаку: положительные поступления зелёным, отрицательные списания красным,
|
|
||||||
плановые/нефактические значения серым, если такие строки отображаются в блоке.
|
|
||||||
- Блок показывает итог по отображаемым доходным операциям.
|
|
||||||
- Блок содержит ссылку на подробную вкладку `/broker/:accountId/operations`.
|
|
||||||
- Если текущий endpoint операций не позволяет корректно получить доходные операции без изменения
|
|
||||||
backend-контракта, первая реализация должна явно зафиксировать это в `plan.md` перед изменением API.
|
|
||||||
- В этой итерации блок `Доходы` не расширяется до полной истории комиссий/налогов; цветовая семантика
|
|
||||||
отрицательных сумм должна быть готова для отображаемых строк, но API-контракт не меняется.
|
|
||||||
|
|
||||||
### 6. Блок `Аналитика доходности`
|
|
||||||
|
|
||||||
- Блок использует существующий endpoint `/api/v1/broker/accounts/:accountId/analytics`.
|
|
||||||
- Отображаются: пополнения, выводы, нетто вложено, дивиденды, купоны, всего получено, доходность.
|
|
||||||
- Денежные значения analytics отображаются с символом валюты `₽` для RUB.
|
|
||||||
- Карточки analytics используют цветовую семантику: положительные потоки и полученные доходы —
|
|
||||||
зелёный тон, отрицательные выводы и отрицательное нетто — красный тон, нейтральные/нулевые значения —
|
|
||||||
нейтральный тон.
|
|
||||||
- При отсутствии analytics data блок показывает спокойное пустое состояние.
|
|
||||||
- Ошибка analytics не ломает остальные блоки.
|
|
||||||
|
|
||||||
### 7. Блок `Аллокация`
|
|
||||||
|
|
||||||
- Используется существующий расчёт `buildBrokerAllocation`.
|
|
||||||
- Вместо donut-диаграммы используется горизонтальный bar chart: каждый сектор — полоса с процентом,
|
|
||||||
подписью и суммой.
|
|
||||||
- Сверху блока показывается итоговая стоимость портфеля.
|
|
||||||
- Каждая полоса содержит: название сектора, долю в процентах, сумму в валюте.
|
|
||||||
- Цвета полос соответствуют существующей палитре аллокации (акции, облигации, ETF, деньги, прочие).
|
|
||||||
- Отрицательные значения отображаются текстом без полосы.
|
|
||||||
- Текст подписей контрастный и читаемый на всех цветах фона.
|
|
||||||
- Информация остаётся понятной без различения цветов.
|
|
||||||
|
|
||||||
### 8. Загрузка, ошибки и пустые состояния
|
|
||||||
|
|
||||||
- Первичная загрузка portfolio показывает dashboard skeleton соответствующей формы.
|
|
||||||
- Ошибка portfolio показывает ошибку overview, потому что без portfolio dashboard не имеет основного
|
|
||||||
контекста.
|
|
||||||
- Загрузка событий и доходов внутри карточек показывает skeleton таблицы соответствующей структуры, а не
|
|
||||||
только текстовую строку загрузки.
|
|
||||||
- Skeleton таблиц событий и доходов должен иметь единый визуальный паттерн: строки соответствуют
|
|
||||||
геометрии таблицы, колонка инструмента шире остальных, для `Доходы` используется тот же паттерн без
|
|
||||||
лишней колонки статуса.
|
|
||||||
- Ошибка событий, доходов или analytics отображается внутри соответствующей карточки.
|
|
||||||
- Пустые события, пустые доходы и пустая analytics имеют отдельные понятные сообщения.
|
|
||||||
- Недоступные отдельные значения отображаются как `—`, не подменяются нулём.
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Backend остаётся единственным клиентом T-Bank и MOEX.
|
|
||||||
- В первой версии не добавляется график истории стоимости портфеля.
|
|
||||||
- В первой версии не добавляется backend storage/API для снапшотов стоимости портфеля.
|
|
||||||
- Тёмная тема и переключатель темы не входят в область фичи.
|
|
||||||
- Не меняются правила расчёта доходности, событий, операций и аллокации.
|
|
||||||
- Не удаляются существующие detailed вкладки счёта.
|
|
||||||
- Не изменяется URL-структура `/broker/:accountId/*`.
|
|
||||||
|
|
||||||
## Backlog
|
|
||||||
|
|
||||||
Идея `Broker portfolio value history` вынесена в `docs/inbox.md` и `docs/roadmap.md`: хранить снапшоты
|
|
||||||
стоимости брокерского счёта и позже заменить отсутствие графика полноценным блоком `Стоимость портфеля`.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- `/broker/:accountId` показывает dashboard-композицию: hero KPI, `События`, `Доходы`,
|
|
||||||
`Аналитика доходности`, `Аллокация`.
|
|
||||||
- Навигация счёта отображается горизонтальными вкладками над dashboard-контентом.
|
|
||||||
- На desktop и mobile блоки `События`, `Доходы`, `Аналитика доходности`, `Аллокация` расположены по
|
|
||||||
одному блоку на строке.
|
|
||||||
- На мобильном viewport dashboard читаемо перестраивается в одну колонку.
|
|
||||||
- Hero показывает стоимость портфеля, доходность или fallback, дневное изменение или fallback, всего
|
|
||||||
доходов или fallback.
|
|
||||||
- Все Metric-блоки hero имеют одинаковую высоту; supportingText не создаёт перекоса.
|
|
||||||
- Hero KPI использует цветовую семантику для доходности, дневного изменения и всего полученных доходов.
|
|
||||||
- Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус.
|
|
||||||
- В таблице `События` инструмент отображается двумя строками при наличии названия, тип отображается
|
|
||||||
бейджем, сумма и статус имеют семантические цвета.
|
|
||||||
- Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий.
|
|
||||||
- Блок `События` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня + 7 дней` и использует одно поле
|
|
||||||
периода с popover-календарём на базе community `DateCalendar`.
|
|
||||||
- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам.
|
|
||||||
- В таблице `Доходы` инструмент отображается двумя строками при наличии названия, тип отображается
|
|
||||||
бейджем, сумма имеет семантический цвет.
|
|
||||||
- Блок `Доходы` поддерживает multi-select chip-фильтр типов и cursor-пагинацию по 10 операций.
|
|
||||||
- Блок `Доходы` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле
|
|
||||||
периода с popover-календарём на базе community `DateCalendar`.
|
|
||||||
- В шапке управления периодом не отображается слово `Фильтр`, а действие применения периода не называется
|
|
||||||
`Показать`.
|
|
||||||
- Шапки фильтров `События` и `Доходы` визуально соответствуют единому toolbar из
|
|
||||||
`docs/research/2026-06-27-broker-account-redesign.html`.
|
|
||||||
- Поле периода использует chevron-иконку, а не текстовый символ `v`.
|
|
||||||
- Загрузка событий и доходов отображается skeleton-таблицей.
|
|
||||||
- Блок `Аналитика доходности` показывает данные существующего analytics endpoint.
|
|
||||||
- Блок `Аналитика доходности` отображает RUB как `₽` и использует цветовую семантику карточек.
|
|
||||||
- Блок `Аллокация` показывает горизонтальные бары секторов с названием, долей и суммой, а также
|
|
||||||
итоговую стоимость портфеля.
|
|
||||||
- Ошибка одного вторичного блока не скрывает остальные блоки dashboard.
|
|
||||||
- Существующие detailed вкладки остаются доступны из навигации счёта.
|
|
||||||
- Первая версия не содержит график истории стоимости портфеля и не добавляет API для него.
|
|
||||||
- Дизайн использует светлую DS-тему, а не тёмную тему из `temp.html`.
|
|
||||||
|
|
||||||
## Вне области фичи
|
|
||||||
|
|
||||||
- график стоимости портфеля по датам;
|
|
||||||
- новые исторические снапшоты стоимости;
|
|
||||||
- dark mode;
|
|
||||||
- изменение backend-расчётов доходности;
|
|
||||||
- налоговая аналитика;
|
|
||||||
- экспорт dashboard;
|
|
||||||
- объединение нескольких брокерских счетов в один dashboard.
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
# Редизайн обзора брокерского счёта в инвестиционный дашборд — задачи
|
|
||||||
|
|
||||||
Дата: 2026-06-26 (обновлено 2026-06-27)
|
|
||||||
Статус: в реализации, итерация HTML parity
|
|
||||||
|
|
||||||
## Документация и pre-flight
|
|
||||||
|
|
||||||
- [x] Выбрать scope: `/broker/:accountId` становится единым дашбордом.
|
|
||||||
- [x] Выбрать визуальный стиль: структура `temp.html`, текущая светлая DS-тема.
|
|
||||||
- [x] Исключить график истории стоимости из первой версии.
|
|
||||||
- [x] Добавить backlog-задачу на историю стоимости брокерского портфеля.
|
|
||||||
- [x] Создать ветку `codex/broker-dashboard-redesign`.
|
|
||||||
- [x] Запустить baseline: `rtk npm run test:backend && rtk npm run test:frontend && rtk npm run test:design-system`.
|
|
||||||
- [x] Написать `spec.md`.
|
|
||||||
- [x] Написать `plan.md`.
|
|
||||||
|
|
||||||
## Реализация
|
|
||||||
|
|
||||||
- [x] Добавить pure helpers для фильтрации и суммирования доходных операций dashboard.
|
|
||||||
- [x] Добавить helpers для фильтров дат, пресетов, валидации и mapping income types → operationTypes.
|
|
||||||
- [x] Добавить dashboard presentation helpers для fallback, event labels и statuses.
|
|
||||||
- [x] Добавить локальный `BrokerDashboardCard` pattern или точечно расширить DS, если локального pattern недостаточно.
|
|
||||||
- [x] Добавить `BrokerDashboardHero` с KPI по portfolio и analytics.
|
|
||||||
- [x] Добавить `BrokerDashboardEventsCard` на основе `useBrokerEvents`.
|
|
||||||
- [x] Добавить `BrokerDashboardIncomeCard` на основе `useBrokerOperations`.
|
|
||||||
- [x] Перенести навигацию счёта из левой колонки в горизонтальные вкладки над контентом.
|
|
||||||
- [x] Перестроить dashboard на один блок на строке для `События`, `Доходы`, `Аналитика доходности`, `Аллокация`.
|
|
||||||
- [x] Добавить кликабельные chip-фильтры типов для `События`.
|
|
||||||
- [x] Добавить `BrokerDashboardDateFilter` — переиспользуемый expandable-компонент фильтра дат (пресеты 7д/30д/90д/1г/Всё, from/to поля, Сбросить/Показать).
|
|
||||||
- [x] Подключить `BrokerDashboardDateFilter` в `События` с draft/applied состоянием.
|
|
||||||
- [x] Добавить локальную пагинацию по 10 событий в `События`.
|
|
||||||
- [x] Добавить кликабельные chip-фильтры типов для `Доходы`.
|
|
||||||
- [x] Подключить `BrokerDashboardDateFilter` в `Доходы` с draft/applied состоянием.
|
|
||||||
- [x] Добавить cursor-пагинацию по 10 операций в `Доходы`.
|
|
||||||
- [x] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`.
|
|
||||||
- [x] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации.
|
|
||||||
- [x] Добавить `BrokerDashboardSkeleton`.
|
|
||||||
- [x] Добавить `BrokerDashboard` как top-level composition widget.
|
|
||||||
- [x] Заменить текущий вертикальный обзор в `BrokerAccountOverviewPage` на `BrokerDashboard`.
|
|
||||||
- [x] Добавить unit/component tests для helpers и базовой dashboard composition.
|
|
||||||
- [x] Выровнять hero KPI: все Metric одной высоты, supportingText не раздвигает "Доходность" выше соседей.
|
|
||||||
- [x] Заменить donut-диаграмму аллокации на горизонтальные бары в `BrokerDashboardAllocationCard`.
|
|
||||||
- [x] Убрать слово `Фильтр` из пользовательского текста шапки периода в dashboard-карточках.
|
|
||||||
- [x] Переименовать действие `Показать` в управлении периодом и визуально улучшить шапку фильтров.
|
|
||||||
- [x] Заменить native date inputs на одно поле периода с popover и community `DateCalendar` из `@mui/x-date-pickers`.
|
|
||||||
- [x] Не использовать `@mui/x-date-pickers-pro` и `DateRangePicker`.
|
|
||||||
- [x] Настроить default range событий на `сегодня - 7 дней` / `сегодня + 7 дней`, доходов — на
|
|
||||||
`сегодня - 7 дней` / `сегодня`.
|
|
||||||
- [x] Заменить текстовую загрузку `События` на skeleton таблицы.
|
|
||||||
- [x] Заменить текстовую загрузку `Доходы` на skeleton таблицы.
|
|
||||||
- [x] Обновить component tests под новые тексты, единое поле периода, popover-календарь и skeleton loading states.
|
|
||||||
- [ ] Проверить desktop layout `/broker/2084014113`.
|
|
||||||
- [ ] Проверить mobile layout `/broker/2084014113`.
|
|
||||||
|
|
||||||
## Итерация HTML parity
|
|
||||||
|
|
||||||
- [x] Согласовать статический визуальный эталон `docs/research/2026-06-27-broker-account-redesign.html`.
|
|
||||||
- [x] Обновить `spec.md` под HTML parity: компактные заголовки, единый toolbar, бейджи, цвета, подписи инструментов, `₽`.
|
|
||||||
- [x] Обновить `plan.md` под перенос HTML parity в React-компоненты.
|
|
||||||
- [x] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` с helpers для money tone, type tone, currency symbol и instrument display.
|
|
||||||
- [x] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts`.
|
|
||||||
- [ ] Расширить income helpers так, чтобы строки доходов могли отдавать main/subtitle инструмента без дублирования текста.
|
|
||||||
- [x] Обновить `BrokerDashboardHero`: semantic colors для доходности, дневного изменения и всего полученных доходов.
|
|
||||||
- [x] Обновить `BrokerDashboardCard`: компактный card heading вместо крупного page-level heading.
|
|
||||||
- [x] Обновить `BrokerDashboardDateFilter`: единый toolbar-паттерн и chevron-иконка вместо текстового `v`.
|
|
||||||
- [x] Обновить `BrokerDashboardEventsCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors, status badges.
|
|
||||||
- [x] Обновить `BrokerDashboardIncomeCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors.
|
|
||||||
Бейдж `Купон` приведён к `info` (синий) согласно HTML-эталону `.type-badge.coupon` (`incomeTypeTone` в `dashboardVisual.ts`),
|
|
||||||
`Дивиденд (внешний)` отнесён к `success` (семейство дивидендов).
|
|
||||||
- [x] Обновить `BrokerDashboardTableSkeleton`: общий skeleton-паттерн для событий и доходов с корректной геометрией колонок.
|
|
||||||
- [x] Обновить `BrokerDashboardAnalyticsCard`: `₽` для RUB и positive/negative tone карточек.
|
|
||||||
- [x] Обновить component tests dashboard под HTML parity (analytics tones).
|
|
||||||
- [x] Проверить, что блок `Доходы` не расширяет backend/API и остаётся в рамках текущих income-типов.
|
|
||||||
Исправлен пресет «Всё»: `applyDatePreset('all')` теперь отдаёт широкий диапазон `2000-01-01`–`2099-12-31`
|
|
||||||
вместо пустых `from`/`to`, которые backend (`BrokerEventsQueryDto @Matches`) отвергал с 400. В DateFilter
|
|
||||||
кнопка «Применить период» блокируется при инвертированном диапазоне (`to < from`), чтобы избежать
|
|
||||||
молчаливо пустого ответа (например, будущий `from` при дефолтном `to=сегодня`).
|
|
||||||
- [x] Исправить default-range `События`, чтобы dashboard по умолчанию и через пресеты мог загружать будущие события.
|
|
||||||
- [x] Подтянуть card headers, toolbar label `Тип`, count badges и footer summaries ближе к
|
|
||||||
`docs/research/2026-06-27-broker-account-redesign.html`.
|
|
||||||
- [ ] Проверить desktop layout `/broker/2084014113` против `docs/research/2026-06-27-broker-account-redesign.html`.
|
|
||||||
- [ ] Проверить mobile layout `/broker/2084014113` на viewport `390x844` против `docs/research/2026-06-27-broker-account-redesign.html`.
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
|
|
||||||
- [x] `rtk npm run test:frontend` проходит.
|
|
||||||
- [x] `rtk npm run test:design-system` проходит.
|
|
||||||
- [x] `rtk npm run lint -w apps/frontend` проходит.
|
|
||||||
- [x] `rtk npm run build:frontend` проходит.
|
|
||||||
- [ ] Dashboard соответствует acceptance criteria из `spec.md`.
|
|
||||||
- [x] Существующие вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` остаются доступны.
|
|
||||||
- [x] `graphify update .` выполнен после code changes.
|
|
||||||
|
|
||||||
## Definition of Done для HTML parity
|
|
||||||
|
|
||||||
- [x] `rtk npm run test:frontend -- --run src/widgets/broker-dashboard` проходит. (49/49)
|
|
||||||
- [x] `rtk npm run test:frontend` проходит после HTML parity изменений. (175/175)
|
|
||||||
- [x] `rtk npm run lint -w apps/frontend` проходит после HTML parity изменений.
|
|
||||||
- [x] `rtk npm run build:frontend` проходит после HTML parity изменений.
|
|
||||||
- [ ] На `/broker/2084014113` заголовки карточек, toolbar таблиц, бейджи типов, подписи инструментов,
|
|
||||||
цвета сумм, analytics colors и skeleton визуально соответствуют `docs/research/2026-06-27-broker-account-redesign.html`.
|
|
||||||
Проверено только по тестам и code-to-spec mapping внизу файла — live dev server не запускался
|
|
||||||
в этой итерации (нет backend/auth в среде). Необходима ручная проверка пользователем.
|
|
||||||
- [ ] Нет общего горизонтального overflow на mobile; горизонтальный scroll допускается только внутри таблиц.
|
|
||||||
Требует live dev server + ручной проверки на viewport `390x844`.
|
|
||||||
- [x] `docs/features/broker-dashboard-redesign/tasks.md` обновлён по факту выполнения.
|
|
||||||
|
|
||||||
### Code-to-spec mapping для analytics (HTML parity)
|
|
||||||
|
|
||||||
`BrokerDashboardAnalyticsCard.tsx` (`apps/frontend/src/widgets/broker-dashboard/ui/`) против
|
|
||||||
HTML-эталона (`docs/research/2026-06-27-broker-account-redesign.html:1145-1158`):
|
|
||||||
|
|
||||||
| HTML reference element | React-компонент / data-testid | Спецификация §6 (spec.md:165-180) |
|
|
||||||
|------------------------|------------------------------------------------------------|----------------------------------------------------------------------|
|
|
||||||
| `<section aria-labelledby="analytics-title">` | `BrokerDashboardCard` с `ariaLabel="Аналитика доходности"` | Секция с заголовком, доступная по aria-label |
|
|
||||||
| `<h2>Аналитика доходности</h2>` | Заголовок карточки | Компактный card heading (§2) |
|
|
||||||
| `.analytics-item.positive .analytics-value` | `data-testid="dashboard-analytics-totalDeposits"` (tone `positive`) | Пополнения — positive (§6, AC `Блок Аналитика доходности`) |
|
|
||||||
| `.analytics-item.negative .analytics-value` для Выводы | `data-testid="dashboard-analytics-totalWithdrawn"` (tone `negative`, префикс `−`) | Выводы — negative, всегда со знаком `−` (§6) |
|
|
||||||
| `.analytics-item.negative .analytics-value` для Нетто (если нетто<0) | `data-testid="dashboard-analytics-netInvested"` (tone `negative` при `value<0`) | Нетто — sign-based (§6) |
|
|
||||||
| `.analytics-item.positive .analytics-value` для Дивиденды/Купоны/Всего получено | `data-testid="dashboard-analytics-totalDividends"`, `…-totalCoupons`, `…-totalReceived` | Positive если `value > 0`, neutral если `value === 0` (§6) |
|
|
||||||
| `113 773,03 ₽` (валюта) | `formatDashboardCurrency` через `shared/lib/formatters` | RUB отображается как `₽` (AC, §2) |
|
|
||||||
|
|
||||||
Тесты в `BrokerDashboard.test.tsx`:
|
|
||||||
|
|
||||||
- `renders analytics card with the ₽ symbol and no "RUB" code` — подтверждает замену `RUB` на `₽`.
|
|
||||||
- `applies positive, negative and neutral tones to analytics values` — подтверждает tone-атрибуты
|
|
||||||
для каждого поля с разнообразными значениями (`totalDeposits: 1000`, `totalWithdrawn: 250`,
|
|
||||||
`netInvested: -150`, `totalDividends: 75`, `totalCoupons: 0`, `totalReceived: 90`).
|
|
||||||
|
|
||||||
### Что НЕ было проверено в этой итерации
|
|
||||||
|
|
||||||
- Реальный визуальный рендеринг `/broker/2084014113` на desktop и `390x844` mobile.
|
|
||||||
Требуется ручная проверка пользователем с поднятым backend (нужны реальные auth и T-Bank/MOEX
|
|
||||||
прокси). Dev server не запускался.
|
|
||||||
- Поведение отсутствующего/ошибочного analytics под live-нагрузкой. Логика в карточке покрыта
|
|
||||||
тестами, но проверка UX-сообщений и skeleton-states в браузере не делалась.
|
|
||||||
- Реальный viewport на `390x844` для подтверждения отсутствия общего горизонтального overflow.
|
|
||||||
Геометрия таблиц уже переключена на внутренний `overflowX: 'auto'` (`BrokerDashboardEventsCard.tsx:176`,
|
|
||||||
`BrokerDashboardIncomeCard.tsx`), но фактическая вёрстка в браузере не сверялась с эталоном.
|
|
||||||
@ -120,26 +120,21 @@ tooling и часть архитектурных cleanup-задач. После
|
|||||||
- `frontend-shared-boundary-cleanup` — сужение shared/public API
|
- `frontend-shared-boundary-cleanup` — сужение shared/public API
|
||||||
- `frontend-test-hygiene` — минимизация test helpers
|
- `frontend-test-hygiene` — минимизация test helpers
|
||||||
|
|
||||||
**Открыто и перенесено в canonical architecture backlog:**
|
**Открыто, не покрыто ни одной фичей (нуждается в новых задачах):**
|
||||||
|
|
||||||
| # | Приоритет | Debt item | Риск |
|
| # | Приоритет | Debt item | Риск |
|
||||||
|---|-----------|-----------|------|
|
|---|-----------|-----------|------|
|
||||||
| 1 | P0/P1 | T-Bank data isolation by user | multi-tenant data leak |
|
| 1 | P0/P1 | T-Bank data isolation by user | multi-tenant data leak |
|
||||||
| 2 | P1 | Local T-Bank history read-path | история читается напрямую из T-Bank |
|
| 2 | P1 | API envelope double-wrapping | runtime-ответы не соответствуют Swagger |
|
||||||
| 3 | P1/P2 | Session model for multiple surfaces | single-token, нет device-level сессий |
|
| 3 | P1 | Production config & auth security hardening | дефолтные секреты, CORS, error leaking |
|
||||||
| 4 | P2 | Reduce type unsafety (`as any`, `no-explicit-any`) | остаточная type unsafety в contract/runtime boundaries |
|
| 4 | P1 | Local T-Bank history read-path | история читается напрямую из T-Bank |
|
||||||
| 5 | P2 | Expand testing strategy (coverage thresholds, E2E) | нет coverage gates, нет Playwright smoke |
|
| 5 | P1/P2 | Session model for multiple surfaces | single-token, нет device-level сессий |
|
||||||
| 6 | P3 | Route-level lazy loading + performance budgets | eager delivery без явных budgets |
|
| 6 | P2 | Reduce type unsafety (`as any`, `no-explicit-any`) | 95+ в коде, в основном gRPC/T-Bank/screener |
|
||||||
|
| 7 | P2 | Expand testing strategy (coverage thresholds, E2E) | нет coverage gates, нет Playwright |
|
||||||
Эти направления больше не должны жить как независимый frontend-only backlog. Их canonical форма и
|
| 8 | P3 | Route-level lazy loading + performance budgets | 471 KB JS bundle eager, нет budgets |
|
||||||
порядок ведутся в `docs/features/architecture-quality-backlog/spec.md` и `docs/roadmap.md`.
|
|
||||||
|
|
||||||
**Устарело и подлежит пересмотру:**
|
**Устарело и подлежит пересмотру:**
|
||||||
|
|
||||||
- P1 `API envelope double-wrapping` — **resolved**: закрыто фичей `api-envelope-contract`
|
|
||||||
- P1 `Production config & auth security hardening` — первичный scope закрыт в
|
|
||||||
`backend-architecture-refactor`; оставшийся policy/runtime backlog перенесён в
|
|
||||||
`architecture-quality-backlog`
|
|
||||||
- P2 «Eliminate dual frontend API type system» — **resolved**: codegen unification выполнена, `responses.ts` удалён
|
- P2 «Eliminate dual frontend API type system» — **resolved**: codegen unification выполнена, `responses.ts` удалён
|
||||||
- P2 «Decompose large modules» — частично выполнена через shared-boundary-cleanup, backend-декомпозиция вне scope audit-фичи
|
- P2 «Decompose large modules» — частично выполнена через shared-boundary-cleanup, backend-декомпозиция вне scope audit-фичи
|
||||||
- P3 «Prepare financial types for future ledger» — перенесена в deferred, не актуальна без инициативы ledger
|
- P3 «Prepare financial types for future ledger» — перенесена в deferred, не актуальна без инициативы ledger
|
||||||
|
|||||||
@ -166,17 +166,6 @@ cash flow, бюджеты, аналитика, прогнозы и автома
|
|||||||
|
|
||||||
## Frontend-платформа
|
## Frontend-платформа
|
||||||
|
|
||||||
### Добавить историю стоимости брокерского портфеля
|
|
||||||
|
|
||||||
- Сохранять снапшоты полной стоимости брокерского счёта, чтобы строить график динамики портфеля по
|
|
||||||
датам.
|
|
||||||
- Отдельно спроектировать backend storage/API, периодичность обновления, валюту расчёта и правила для
|
|
||||||
пропущенных дней.
|
|
||||||
- На дашборде брокерского счёта заменить временный отказ от графика на полноценный блок `Стоимость
|
|
||||||
портфеля`, когда данные истории будут доступны.
|
|
||||||
- Не реализовывать в первой версии редизайна `/broker/:accountId`: текущая задача использует только
|
|
||||||
уже доступные данные портфеля, событий, операций и аналитики.
|
|
||||||
|
|
||||||
### Перейти к Feature-Sliced Design
|
### Перейти к Feature-Sliced Design
|
||||||
|
|
||||||
- Постепенно привести frontend к FSD-архитектуре с явными границами между `app`, `pages`, `widgets`,
|
- Постепенно привести frontend к FSD-архитектуре с явными границами между `app`, `pages`, `widgets`,
|
||||||
@ -378,12 +367,6 @@ cash flow, бюджеты, аналитика, прогнозы и автома
|
|||||||
> - Health check прокачка — проверки Prisma, MOEX, T-Bank с детальным статусом
|
> - Health check прокачка — проверки Prisma, MOEX, T-Bank с детальным статусом
|
||||||
> - RequestLoggingMiddleware — перевод на `configure()` в AppModule
|
> - RequestLoggingMiddleware — перевод на `configure()` в AppModule
|
||||||
> - MoexClientService split — 6 клиентов вместо God Service, убран `@Global()`
|
> - MoexClientService split — 6 клиентов вместо God Service, убран `@Global()`
|
||||||
>
|
|
||||||
> **Обновление 2026-06-25:** Для следующей итерации создана refactor-only фича
|
|
||||||
> `backend-architecture-refactor`. В неё входят только правки текущего поведения: error masking,
|
|
||||||
> production config hardening, DTO validation, typed T-Bank gRPC boundary, cache metadata и backend docs
|
|
||||||
> sync. Multi-tenancy, local T-Bank read-path, device sessions и ledger-модель остаются отдельными
|
|
||||||
> follow-up фичами, потому что меняют доменную модель или поведение.
|
|
||||||
|
|
||||||
Текущее состояние quality gates хорошее: на момент аудита проходят lint, format-check, backend build,
|
Текущее состояние quality gates хорошее: на момент аудита проходят lint, format-check, backend build,
|
||||||
frontend build, 94 backend-теста и 168 frontend-тестов.
|
frontend build, 94 backend-теста и 168 frontend-тестов.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 648 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 491 KiB |
@ -24,17 +24,6 @@ Roadmap отражает порядок продуктовой работы, н
|
|||||||
|
|
||||||
## Активные эпики
|
## Активные эпики
|
||||||
|
|
||||||
### [Backend Architecture](epics/BackendArchitecture.md)
|
|
||||||
|
|
||||||
Цель: удерживать backend-архитектуру, security boundaries и published contracts в состоянии,
|
|
||||||
пригодном для безопасного развития продукта.
|
|
||||||
|
|
||||||
- [x] [Backend Architecture Improvements](features/backend-architecture-improvements/spec.md) — envelope
|
|
||||||
DTO, screener TTL, domain exceptions, health checks, middleware DI, MoexClient split.
|
|
||||||
- [x] [Backend Architecture Refactor](features/backend-architecture-refactor/spec.md) — refactor-only
|
|
||||||
итерация: error masking, production config hardening, DTO validation, typed T-Bank boundary, cache
|
|
||||||
metadata, backend docs sync.
|
|
||||||
|
|
||||||
### [Портфель брокера](epics/BrokerPortfolio.md)
|
### [Портфель брокера](epics/BrokerPortfolio.md)
|
||||||
|
|
||||||
Цель: дать пользователю целостный доступ к реальным брокерским счетам T-Bank.
|
Цель: дать пользователю целостный доступ к реальным брокерским счетам T-Bank.
|
||||||
@ -93,8 +82,6 @@ Roadmap отражает порядок продуктовой работы, н
|
|||||||
- [x] [MVP](features/moex-vibe/spec.md) — поиск, карточки акций/облигаций, графики, дивиденды.
|
- [x] [MVP](features/moex-vibe/spec.md) — поиск, карточки акций/облигаций, графики, дивиденды.
|
||||||
- [x] [Frontend debt audit and backlog](features/frontend-debt-audit/spec.md) — audit frontend-техдолга,
|
- [x] [Frontend debt audit and backlog](features/frontend-debt-audit/spec.md) — audit frontend-техдолга,
|
||||||
разделение open items на 4 follow-up фичи, синхронизация inbox/roadmap
|
разделение open items на 4 follow-up фичи, синхронизация inbox/roadmap
|
||||||
- [x] [Architecture quality backlog](features/architecture-quality-backlog/spec.md) — docs-only
|
|
||||||
нормализация architecture backlog, синхронизация roadmap и audit/refactor статусов
|
|
||||||
- [x] [frontend-docs-sync](features/frontend-docs-sync/spec.md) — синхронизация docs с состоянием кода
|
- [x] [frontend-docs-sync](features/frontend-docs-sync/spec.md) — синхронизация docs с состоянием кода
|
||||||
- [x] [frontend-infrastructure-hardening](features/frontend-infrastructure-hardening/spec.md) —
|
- [x] [frontend-infrastructure-hardening](features/frontend-infrastructure-hardening/spec.md) —
|
||||||
browser mock mode, env validation, tooling consistency, contract freshness
|
browser mock mode, env validation, tooling consistency, contract freshness
|
||||||
@ -103,7 +90,7 @@ Roadmap отражает порядок продуктовой работы, н
|
|||||||
- [x] [frontend-test-hygiene](features/frontend-test-hygiene/spec.md) — минимизация test helpers,
|
- [x] [frontend-test-hygiene](features/frontend-test-hygiene/spec.md) — минимизация test helpers,
|
||||||
нормализация conventions
|
нормализация conventions
|
||||||
|
|
||||||
### Backend Architecture Improvements
|
### [Backend Architecture Improvements](features/backend-architecture-improvements/spec.md)
|
||||||
|
|
||||||
- [x] Shared envelope DTO — единый `ApiResponseMeta` вместо 6 дублирующихся классов
|
- [x] Shared envelope DTO — единый `ApiResponseMeta` вместо 6 дублирующихся классов
|
||||||
- [x] Screener TTL — отдельный кеш-параметр `CACHE_SCREENER_TTL` (900s)
|
- [x] Screener TTL — отдельный кеш-параметр `CACHE_SCREENER_TTL` (900s)
|
||||||
@ -114,22 +101,16 @@ Roadmap отражает порядок продуктовой работы, н
|
|||||||
|
|
||||||
## Кандидаты следующих фич
|
## Кандидаты следующих фич
|
||||||
|
|
||||||
Ниже опубликован canonical architecture backlog после `architecture-quality-backlog`. Старые granular
|
|
||||||
заметки folded into these tracks и не должны вестись как параллельные независимые backlog-списки.
|
|
||||||
|
|
||||||
- [x] [Миграция таблиц на дизайн-систему](features/table-migration/spec.md) — DividendsTable,
|
- [x] [Миграция таблиц на дизайн-систему](features/table-migration/spec.md) — DividendsTable,
|
||||||
ScreenerTable, SharePositionTable, BondPositionTable мигрированы на `DataTable`. Legacy `shared/ui/Table`
|
ScreenerTable, SharePositionTable, BondPositionTable мигрированы на `DataTable`. Legacy `shared/ui/Table`
|
||||||
и `TableSkeleton` удалены.
|
и `TableSkeleton` удалены.
|
||||||
- [ ] T-Bank data isolation and ownership boundaries (P0/P1) — изоляция T-Bank данных по пользователям,
|
- [ ] T-Bank data isolation and multi-tenancy (P0/P1) — изолировать данные T-Bank по пользователям,
|
||||||
ownership model и backend query boundary до следующих data-flow изменений
|
ownership модель
|
||||||
- [ ] Backend runtime and auth hardening (P1) — отдельная security/runtime policy итерация: rate limiting,
|
- [x] API envelope runtime contract (P1) — устранить double-wrapping, унифицировать envelope
|
||||||
auth surface hardening, explicit production rules
|
- [ ] Auth security hardening (P1) — production-секреты, CORS allowlist, error masking, rate limiting
|
||||||
- [ ] Local T-Bank read-path (P1) — локальная persisted read-модель операций/истории вместо прямого
|
- [ ] Local T-Bank read-path (P1) — чтение истории операций из локальной БД вместо прямого вызова T-Bank
|
||||||
read-through из T-Bank
|
- [ ] Session model for multiple surfaces (P1/P2) — device-level сессии, rotation, reuse detection
|
||||||
- [ ] Session model for multiple surfaces (P1/P2) — device-level sessions, rotation, reuse detection,
|
- [ ] Type safety hardening (P2) — включение `no-explicit-any`, устранение `as any` в gRPC/screener/tests
|
||||||
явный session contract для нескольких клиентских поверхностей
|
- [ ] Testing strategy expansion (P2) — coverage thresholds, contract tests, Playwright smoke
|
||||||
- [ ] Contract, type-safety, and frontend delivery hardening (P2/P3) — устранение остаточного `as any`,
|
- [ ] Frontend delivery optimization (P3) — route-level lazy loading, performance budgets
|
||||||
укрепление API/query boundaries, quality/performance gates и lazy-loading budgets
|
|
||||||
- [ ] Broker portfolio value history (P2/P3) — хранить снапшоты стоимости брокерского счёта и показать
|
|
||||||
график динамики портфеля на дашборде
|
|
||||||
- [x] Broker-events — UX доработки и смешанный календарь.
|
- [x] Broker-events — UX доработки и смешанный календарь.
|
||||||
|
|||||||
455
package-lock.json
generated
455
package-lock.json
generated
@ -92,7 +92,6 @@
|
|||||||
"@moex-vibe/design-system": "*",
|
"@moex-vibe/design-system": "*",
|
||||||
"@mui/icons-material": "^6.5.0",
|
"@mui/icons-material": "^6.5.0",
|
||||||
"@mui/material": "^6.5.0",
|
"@mui/material": "^6.5.0",
|
||||||
"@mui/x-date-pickers": "^7.29.4",
|
|
||||||
"@tanstack/react-query": "^5.20.0",
|
"@tanstack/react-query": "^5.20.0",
|
||||||
"@tanstack/react-router": "^1.170.16",
|
"@tanstack/react-router": "^1.170.16",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
@ -7427,92 +7426,6 @@
|
|||||||
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@mui/x-date-pickers": {
|
|
||||||
"version": "7.29.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.29.4.tgz",
|
|
||||||
"integrity": "sha512-wJ3tsqk/y6dp+mXGtT9czciAMEO5Zr3IIAHg9x6IL0Eqanqy0N3chbmQQZv3iq0m2qUpQDLvZ4utZBUTJdjNzw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.25.7",
|
|
||||||
"@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0",
|
|
||||||
"@mui/x-internals": "7.29.0",
|
|
||||||
"@types/react-transition-group": "^4.4.11",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"prop-types": "^15.8.1",
|
|
||||||
"react-transition-group": "^4.4.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/mui-org"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@emotion/react": "^11.9.0",
|
|
||||||
"@emotion/styled": "^11.8.1",
|
|
||||||
"@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
|
||||||
"@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
|
||||||
"date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0",
|
|
||||||
"date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0",
|
|
||||||
"dayjs": "^1.10.7",
|
|
||||||
"luxon": "^3.0.2",
|
|
||||||
"moment": "^2.29.4",
|
|
||||||
"moment-hijri": "^2.1.2 || ^3.0.0",
|
|
||||||
"moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0",
|
|
||||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@emotion/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@emotion/styled": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"date-fns": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"date-fns-jalali": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"dayjs": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"luxon": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"moment": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"moment-hijri": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"moment-jalaali": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@mui/x-internals": {
|
|
||||||
"version": "7.29.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz",
|
|
||||||
"integrity": "sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.25.7",
|
|
||||||
"@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/mui-org"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||||
@ -13237,20 +13150,6 @@
|
|||||||
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
|
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/accepts": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"mime-types": "^3.0.0",
|
|
||||||
"negotiator": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.17.0",
|
"version": "8.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||||
@ -15376,20 +15275,6 @@
|
|||||||
"integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==",
|
"integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/content-disposition": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/content-type": {
|
"node_modules/content-type": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
@ -15439,16 +15324,6 @@
|
|||||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cookie-signature": {
|
|
||||||
"version": "1.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
|
||||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.6.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/copy-text-to-clipboard": {
|
"node_modules/copy-text-to-clipboard": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.2.tgz",
|
||||||
@ -18688,182 +18563,6 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express": {
|
|
||||||
"version": "5.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"accepts": "^2.0.0",
|
|
||||||
"body-parser": "^2.2.1",
|
|
||||||
"content-disposition": "^1.0.0",
|
|
||||||
"content-type": "^1.0.5",
|
|
||||||
"cookie": "^0.7.1",
|
|
||||||
"cookie-signature": "^1.2.1",
|
|
||||||
"debug": "^4.4.0",
|
|
||||||
"depd": "^2.0.0",
|
|
||||||
"encodeurl": "^2.0.0",
|
|
||||||
"escape-html": "^1.0.3",
|
|
||||||
"etag": "^1.8.1",
|
|
||||||
"finalhandler": "^2.1.0",
|
|
||||||
"fresh": "^2.0.0",
|
|
||||||
"http-errors": "^2.0.0",
|
|
||||||
"merge-descriptors": "^2.0.0",
|
|
||||||
"mime-types": "^3.0.0",
|
|
||||||
"on-finished": "^2.4.1",
|
|
||||||
"once": "^1.4.0",
|
|
||||||
"parseurl": "^1.3.3",
|
|
||||||
"proxy-addr": "^2.0.7",
|
|
||||||
"qs": "^6.14.0",
|
|
||||||
"range-parser": "^1.2.1",
|
|
||||||
"router": "^2.2.0",
|
|
||||||
"send": "^1.1.0",
|
|
||||||
"serve-static": "^2.2.0",
|
|
||||||
"statuses": "^2.0.1",
|
|
||||||
"type-is": "^2.0.1",
|
|
||||||
"vary": "^1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express/node_modules/body-parser": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"bytes": "^3.1.2",
|
|
||||||
"content-type": "^2.0.0",
|
|
||||||
"debug": "^4.4.3",
|
|
||||||
"http-errors": "^2.0.1",
|
|
||||||
"iconv-lite": "^0.7.2",
|
|
||||||
"on-finished": "^2.4.1",
|
|
||||||
"qs": "^6.15.2",
|
|
||||||
"raw-body": "^3.0.2",
|
|
||||||
"type-is": "^2.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express/node_modules/body-parser/node_modules/content-type": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express/node_modules/iconv-lite": {
|
|
||||||
"version": "0.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
|
||||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express/node_modules/media-typer": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express/node_modules/qs": {
|
|
||||||
"version": "6.15.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
|
||||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"es-define-property": "^1.0.1",
|
|
||||||
"side-channel": "^1.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express/node_modules/raw-body": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"bytes": "~3.1.2",
|
|
||||||
"http-errors": "~2.0.1",
|
|
||||||
"iconv-lite": "~0.7.0",
|
|
||||||
"unpipe": "~1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express/node_modules/type-is": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"content-type": "^2.0.0",
|
|
||||||
"media-typer": "^1.1.0",
|
|
||||||
"mime-types": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express/node_modules/type-is/node_modules/content-type": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/exsolve": {
|
"node_modules/exsolve": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||||
@ -19167,28 +18866,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/finalhandler": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "^4.4.0",
|
|
||||||
"encodeurl": "^2.0.0",
|
|
||||||
"escape-html": "^1.0.3",
|
|
||||||
"on-finished": "^2.4.1",
|
|
||||||
"parseurl": "^1.3.3",
|
|
||||||
"statuses": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/find-cache-dir": {
|
"node_modules/find-cache-dir": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz",
|
||||||
@ -19440,16 +19117,6 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"url": "https://github.com/sponsors/rawify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fresh": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fs-extra": {
|
"node_modules/fs-extra": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||||
@ -21495,13 +21162,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/is-promise": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/is-property": {
|
"node_modules/is-property": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||||
@ -23776,19 +23436,6 @@
|
|||||||
"node": ">= 4.0.0"
|
"node": ">= 4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/merge-descriptors": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/merge-stream": {
|
"node_modules/merge-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||||
@ -25685,23 +25332,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mime-types": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"mime-db": "^1.54.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mimic-fn": {
|
"node_modules/mimic-fn": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||||
@ -26085,16 +25715,6 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/negotiator": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/neo-async": {
|
"node_modules/neo-async": {
|
||||||
"version": "2.6.2",
|
"version": "2.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||||
@ -30712,34 +30332,6 @@
|
|||||||
"points-on-path": "^0.2.1"
|
"points-on-path": "^0.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/router": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "^4.4.0",
|
|
||||||
"depd": "^2.0.0",
|
|
||||||
"is-promise": "^4.0.0",
|
|
||||||
"parseurl": "^1.3.3",
|
|
||||||
"path-to-regexp": "^8.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/router/node_modules/path-to-regexp": {
|
|
||||||
"version": "8.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
|
||||||
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/rtlcss": {
|
"node_modules/rtlcss": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz",
|
||||||
@ -31053,33 +30645,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/send": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "^4.4.3",
|
|
||||||
"encodeurl": "^2.0.0",
|
|
||||||
"escape-html": "^1.0.3",
|
|
||||||
"etag": "^1.8.1",
|
|
||||||
"fresh": "^2.0.0",
|
|
||||||
"http-errors": "^2.0.1",
|
|
||||||
"mime-types": "^3.0.2",
|
|
||||||
"ms": "^2.1.3",
|
|
||||||
"on-finished": "^2.4.1",
|
|
||||||
"range-parser": "^1.2.1",
|
|
||||||
"statuses": "^2.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/seq-queue": {
|
"node_modules/seq-queue": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
||||||
@ -31315,26 +30880,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/serve-static": {
|
|
||||||
"version": "2.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
|
||||||
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"encodeurl": "^2.0.0",
|
|
||||||
"escape-html": "^1.0.3",
|
|
||||||
"parseurl": "^1.3.3",
|
|
||||||
"send": "^1.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/set-cookie-parser": {
|
"node_modules/set-cookie-parser": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
|
||||||
|
|||||||
@ -59,22 +59,4 @@ describe('Chip', () => {
|
|||||||
renderWithTheme(<Chip label="Static" />);
|
renderWithTheme(<Chip label="Static" />);
|
||||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onClick when clickable chip clicked', async () => {
|
|
||||||
const handleClick = vi.fn();
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderWithTheme(<Chip label="Clickable" onClick={handleClick} />);
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Clickable' }));
|
|
||||||
|
|
||||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders unselected clickable chip as outlined', () => {
|
|
||||||
renderWithTheme(<Chip label="Inactive" onClick={() => {}} selected={false} />);
|
|
||||||
|
|
||||||
const chip = screen.getByText('Inactive').closest('.MuiChip-root')!;
|
|
||||||
expect(chip.classList.contains('MuiChip-outlined')).toBe(true);
|
|
||||||
expect(chip).toHaveAttribute('aria-pressed', 'false');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
|
||||||
import { Chip as MuiChip } from '@mui/material';
|
import { Chip as MuiChip } from '@mui/material';
|
||||||
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
|
|
||||||
type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'error';
|
type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
@ -15,28 +15,14 @@ export interface ChipProps {
|
|||||||
label: string;
|
label: string;
|
||||||
tone?: Tone;
|
tone?: Tone;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onClick?: () => void;
|
|
||||||
selected?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Chip({
|
export function Chip({ label, tone = 'neutral', onDelete }: ChipProps) {
|
||||||
label,
|
|
||||||
tone = 'neutral',
|
|
||||||
onDelete,
|
|
||||||
onClick,
|
|
||||||
selected = true,
|
|
||||||
disabled,
|
|
||||||
}: ChipProps) {
|
|
||||||
return (
|
return (
|
||||||
<MuiChip
|
<MuiChip
|
||||||
label={label}
|
label={label}
|
||||||
color={TONE_MAP[tone]}
|
color={TONE_MAP[tone]}
|
||||||
variant={selected ? 'filled' : 'outlined'}
|
|
||||||
onClick={onClick}
|
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
disabled={disabled}
|
|
||||||
aria-pressed={onClick ? selected : undefined}
|
|
||||||
{...(onDelete ? { deleteIcon: <CancelIcon aria-label={`Remove ${label}`} /> } : {})}
|
{...(onDelete ? { deleteIcon: <CancelIcon aria-label={`Remove ${label}`} /> } : {})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user