Compare commits

..

43 Commits

Author SHA1 Message Date
40ef3e9059 docs: update tasks.md with skeleton parity and analytics rewrite summary
All checks were successful
CI / ci (pull_request) Successful in 15m26s
CI / ci (push) Successful in 15m33s
2026-06-27 20:56:58 +03:00
d8070209ed feat(frontend): add structural skeletons matching reference loading states
- Portfolio history: chart-loading skeleton with shimmer area and dashed guide
- Analytics: 2 summary cards + 6 detail grid cards with skel bars
- Title/yield: 3 skeleton bars (label, value, daily) when portfolio loading
- Events card already had skeleton table (no change needed)
- Fix test expectation for operations limit (7 -> 100)
2026-06-27 20:47:04 +03:00
3281866c43 refactor(tbank): rewrite BrokerAnalyticsService to use T-Bank API directly
Replace Prisma-based analytics with direct T-Bank API calls:
- GetPortfolio for expectedYield (real portfolio return)
- GetOperationsByCursor with pagination for full operation history
- Remove incorrect totalReturnPercent formula, use T-Bank expectedYield instead
2026-06-27 20:39:32 +03:00
8b24d8b82b fix(frontend): align badges, pagination, chart labels with reference
- Replace MUI Chip in events card with custom Badge matching reference
  colors (blue coupon, green dividend, amber maturity, red tax/fee)
- Add working pagination (useState, 7 per page, ←/→ handlers)
- Increase events API limit to 100 for multi-page support
- Fix chart month labels: left-align first, right-align last
  to prevent overflow beyond container edges
2026-06-27 20:16:40 +03:00
d21e2411b8 feat(frontend): complete broker account overview HTML parity
- Remove BrokerDashboardHero and BrokerDashboardIncomeCard from overview
- Reorder blocks to spec: PortfolioHistory → Analytics → Allocation → Events
- Add BrokerPortfolioHistoryCard with SVG line/area chart (6 months)
- Switch EventsCard from calendar events to executed operations
- Update AnalyticsCard: add totalFees/totalTaxesPaid, remove netInvested
- Update AllocationCard: title 'Структура', only shares/bonds/cash
- Add inline yield display in BrokerAccountLayout
- Clean up unused income filter state and dashboardFilters lib
- Update tests to match new component structure
- Visual QA screenshots (desktop + mobile)
2026-06-27 19:54:03 +03:00
ff0e11b303 feat(tbank): add totalFees/totalTaxesPaid, portfolio history endpoint, and category filtering
- Add totalFees and totalTaxesPaid to BrokerAnalyticsDto and service
- Add GET /accounts/:accountId/portfolio/history endpoint with estimated v1 read-model
- Add categories query param to operations endpoint for filtering by category
- Update tests and module registration
2026-06-27 19:30:32 +03:00
c4b93f9f0c docs: add broker overview html parity spec 2026-06-27 19:22:54 +03:00
3fdceb9438 feat: add example.html 2026-06-27 18:39:02 +03:00
fee179d8e9 fix(frontend): align broker dashboard HTML parity
All checks were successful
CI / ci (pull_request) Successful in 15m41s
CI / ci (push) Successful in 13m50s
2026-06-27 15:23:55 +03:00
58b8075583 refactor(frontend): deduplicate analytics metric cells and restore text states 2026-06-27 13:52:37 +03:00
d7b65e256f docs: mark HTML parity analytics task and DoD verified
- Flip BrokerDashboardAnalyticsCard + analytics component tests
  checkboxes for the parity iteration.
- Mark verified DoD items (dashboard tests 49/49, full frontend tests
  175/175, lint, build).
- Add code-to-spec mapping table linking the analytics card to the
  HTML reference and spec §6 for the next agent / reviewer.
- Be explicit that live visual + mobile-overflow checks still need
  manual verification with a running backend.
2026-06-27 13:45:48 +03:00
b12c2741cd feat(frontend): align analytics card with HTML parity
- Render RUB values via formatDashboardCurrency so RUB shows as ₽
  instead of 'RUB', matching the hero, events and income cards.
- Apply semantic color tones per spec §6:
  * Пополнения/Дивиденды/Купоны/Всего получено → positive when > 0,
    neutral when 0.
  * Выводы → prefix value with '−' and render with negative tone
    whenever the underlying amount is positive.
  * Нетто → sign-based tone.
- Add data-testid and data-tone attributes on each analytics value so
  tests and downstream styling can address each metric.
- Update the analytics mock in BrokerDashboard.test.tsx to cover
  positive, negative (net invested + withdrawals) and zero values, and
  add two new tests asserting ₽ rendering and the tone data attributes.
2026-06-27 13:44:31 +03:00
a1377fa5c2 feat(frontend): allow aria-label on BrokerDashboardCard
Support setting an accessible name on the dashboard card section so
consumers can query sections via getByLabelText in tests and assistive
tech, matching the hero pattern in BrokerDashboardHero.
2026-06-27 13:44:25 +03:00
27fb6e77be refactor(frontend): polish dashboard tables per code review
- Drop unused `letterSpacing: 0.2` from TH_SX in both table cards
  (HTML mockup has no letter-spacing on headers)
- Move `eventStatusTone` from EventsCard to dashboardVisual.ts
  for symmetry with `eventTypeTone` (single source of tone semantics)
- Drop unused `columnWidths` prop and `widthsFor` override branch
  in BrokerDashboardTableSkeleton (no caller passes it)
- Drop redundant `role="presentation"` on skeleton wrapper
  (rows already carry `aria-hidden="true"`)
- Drop unused `TypeTone` import from EventsCard after moving
  eventStatusTone out
2026-06-27 13:39:23 +03:00
94f8bb876d fix(frontend): correct events amount sign for actual-negative rows
Spec review of Task 9 caught that the events amount prefix used '+'
unconditionally for any actual source, producing '+-87,00 ₽' on negative
actual amounts. Mirror the income card sign handling: '+' for positive,
Unicode '−' (U+2212) for negative, '~' for forecast (regardless of sign).

Extract a small eventAmountDisplay helper and tighten the corresponding
test assertion to require exact equality instead of substring match, so
this regression class is caught next time.
2026-06-27 13:35:48 +03:00
3bad694dce feat(frontend): align dashboard tables with HTML parity
- Add thead with semantic column headers to events and income tables
- Render instruments as main (ticker/ISIN) + subtitle (name) via instrumentDisplay
- Type column uses compact Chip with eventTypeTone/incomeTypeTone; DIV_EXT
  shows distinct label 'Дивиденд (внешний)'
- Amount column applies moneyTone (positive=green, negative=red, planned=neutral)
  via moneyToneToColor; sign prefixes (+/-/~) preserved per spec
- Status column: 'Поступило' (success) vs 'Ожидается' (neutral)
- Income sum semantics preserved (sum of current page rows)
- Skeleton: unified column-width and variant pattern for both tables
- Drop legacy DashboardIncomeRow.instrument alias (now uses instrumentMain/Subtitle)
- Extract moneyToneToColor helper from hero (single source of MUI color mapping)
- Dashboard tests cover thead, badges, subtitles, signed tones, and that
  tables show ₽ instead of RUB for RUB amounts
2026-06-27 13:33:02 +03:00
21df354df6 fix(frontend): align dashboard card heading hierarchy
BrokerDashboardCard previously rendered <Heading level={3} size="section">,
which produced an <h3> under the <h1> account name in BrokerAccountLayout,
skipping the <h2> level. Use level={2} so the semantic HTML is a proper
<h2> while keeping the compact "section" visual size.

Also extract the byte-identical toolbar wrapper (grid + chips + date filter
slot) shared by BrokerDashboardEventsCard and BrokerDashboardIncomeCard
into BrokerDashboardTableToolbar to remove duplicated sx config.
2026-06-27 13:21:15 +03:00
0a1ae0552c feat(frontend): apply hero, card and toolbar HTML parity 2026-06-27 13:07:06 +03:00
1a2937ad58 refactor(frontend): tighten dashboard visual helpers per code review 2026-06-27 12:57:03 +03:00
af0aaeda9d feat(frontend): add dashboard visual helpers for HTML parity
Introduce dashboardVisual.ts with moneyTone, formatDashboardCurrency,
eventTypeTone, incomeTypeTone, and instrumentDisplay helpers used by
the broker dashboard to match the HTML reference prototype.

Extend dashboardIncome.ts: typeLabel now distinguishes DIV_EXT
('Дивиденд (внешний)'), and rows expose instrumentMain/instrumentSubtitle
via instrumentDisplay while keeping instrument as a derived alias for
existing callers.

Add resetDashboardFilters factory in dashboardFilters.ts so Task 3 can
wire the reset action without re-churning filter types.
2026-06-27 12:46:39 +03:00
ae207b0cb3 feat: improove design, add change spec 2026-06-27 12:33:58 +03:00
d4d1dd980e feat: replace native date inputs with single DateCalendar field in broker dashboard
- Replace two MUI DatePicker components with a single visual range field
- Click opens Popover with community DateCalendar (no DateRangePicker/pro)
- First click sets start, second sets end; if end < start, range resets
- Remove isOpen/onToggle props — Popover managed locally
- Add BrokerDashboardTableSkeleton for events/income loading
- Set default weekly range (today-7d / today) for both events and income
- Remove 'Фильтр' label, rename 'Показать' → 'Применить период'
- Install @mui/x-date-pickers@7
- Update tests for new UI and skeleton loading states
- Align spec.md, plan.md, tasks.md with implementation
2026-06-27 11:48:10 +03:00
0116c34a9f feat: improove design 2026-06-27 11:11:25 +03:00
d804d43616 docs: align broker dashboard redesign docs 2026-06-27 10:45:20 +03:00
8df94a51dd feat: broker dashboard redesign with top tabs, single-column layout, and clickable chip filters
- Move navigation from left sidebar to horizontal top tabs in BrokerAccountLayout
- Change dashboard blocks to single-column layout (Events, Income, Analytics, Allocation)
- Extend DS Chip with onClick/selected/disabled/aria-pressed
- Add clickable chip filters to Events and Income cards with immediate filtering
- Disable backend query when all types deselected (show validation message)
- Add useBrokerEvents/useBrokerOperations options.enabled param
- Update dashboard skeleton to single-column shape
- Add userEvent tests for chip filter interactions
2026-06-26 10:47:08 +03:00
c429d6a43a feat(frontend): redesign broker account overview as investment dashboard
Replace vertical overview with dashboard composition:
- BrokerDashboardHero with portfolio KPI, return, daily change, total income
- BrokerDashboardEventsCard with compact events table and local pagination
- BrokerDashboardIncomeCard with income operations table and cursor pagination
- BrokerDashboardAnalyticsCard with analytics Metric grid
- BrokerDashboardAllocationCard wrapping existing donut chart
- BrokerDashboardSkeleton matching dashboard shape
- BrokerDashboardCard local card pattern
- Pure lib helpers: dashboardIncome, dashboardFilters, dashboardFormatters
- Unit tests for income helpers (6) and dashboard composition (1)
2026-06-26 10:18:57 +03:00
49dac140ff docs: plan broker dashboard redesign 2026-06-26 09:45:04 +03:00
8dab915edd fix(backend): isolate health service unit test
All checks were successful
CI / ci (push) Successful in 15m16s
2026-06-26 07:58:31 +03:00
bbbdca30f6 docs: merge architecture backlog branch 2026-06-26 07:54:16 +03:00
99a917392a docs: publish architecture quality backlog 2026-06-26 07:50:04 +03:00
47e65c9b46 docs: add architecture quality backlog 2026-06-25 22:53:22 +03:00
7af8691e0c docs: clarify graphify usage for tracing 2026-06-25 22:53:06 +03:00
4cb0b8d450 docs: clarify graphify usage for tracing 2026-06-25 22:51:01 +03:00
293838ba68 docs: clarify graphify usage for tracing 2026-06-25 22:26:08 +03:00
b27d6ad836 chore: remove redundant express dependency and fix docs
Some checks failed
CI / ci (pull_request) Failing after 2m58s
CI / ci (push) Failing after 2m53s
2026-06-25 22:13:11 +03:00
53bab79c5f docs: mark all tasks complete in backend-architecture-refactor task list 2026-06-25 22:01:23 +03:00
0e7ecbb1ef docs: sync backend docs with refactored MOEX, health envelope, and operations/sync endpoint 2026-06-25 22:00:44 +03:00
b87ed761ed fix: preserve cachedAt metadata on cache hits 2026-06-25 21:58:05 +03:00
fe938b2706 refactor: localize T-Bank gRPC as any casts behind typed facade methods 2026-06-25 21:55:23 +03:00
af0c93fd34 fix: harden portfolio DTO validation for quantity and date fields 2026-06-25 21:51:08 +03:00
db28f46481 feat: add production config hardening for JWT secrets and CORS origins 2026-06-25 21:50:07 +03:00
0e8f65c457 fix: mask internal Error.message in unhandled 500 responses 2026-06-25 21:44:35 +03:00
120db3c2ab docs: add backend-architecture-refactor SDD artifacts and update epic/roadmap/inbox 2026-06-25 21:41:57 +03:00
90 changed files with 9980 additions and 370 deletions

View File

@ -507,13 +507,19 @@ roadmap.md и inbox.md никогда не являются основанием
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
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.
When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else.
Rules:
- 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.
- 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.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
- Используй `graphify` в первую очередь, когда задача связана с архитектурой, границами модулей, кросс-файловым влиянием или трассировкой потока данных.
- Для таких вопросов сначала запускай `graphify query "<question>"`, если существует `graphify-out/graph.json`. Для связей используй `graphify path "<A>" "<B>"`, для точечных концептов — `graphify explain "<concept>"`. Обычно это даёт гораздо более узкий подграф, чем `GRAPH_REPORT.md` или raw grep.
- Предпочитай `graphify query` перед raw grep, когда нужен кратчайший путь между концептами, мост между комьюнити или трассировка того, как один подсистемный блок достигает другого.
- Для отладки багов начинай с симптома и спрашивай у graphify путь зависимости, bridge nodes или модули, которые могут объяснить неожиданное поведение.
- Если `graphify` возвращает только общую структуру, переходи к `serena` за символ-уровневыми фактами и затем повторяй `graphify` с более узким вопросом, где названы конкретные файлы, модули или сервисы.
- 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).

View File

@ -125,6 +125,8 @@ docs/
| `npm run format` | Prettier для всех `*.{ts,tsx}` |
| `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 для разработки компонентов.
Интеграционные тесты с MOEX: `npm run test:integration -w apps/backend`.

View File

@ -0,0 +1,67 @@
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',
}),
);
});
});

View File

@ -26,8 +26,9 @@ export class HttpExceptionFilter implements ExceptionFilter {
error = (r.error as string) || exception.name;
}
} else if (exception instanceof Error) {
message = exception.message;
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
} else {
this.logger.error(`Unhandled non-error exception: ${String(exception)}`);
}
response.status(status).json({

View File

@ -0,0 +1,76 @@
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',
]);
});
});

View File

@ -1,5 +1,14 @@
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', () => ({
port: parseInt(process.env.PORT || '3000', 10),
database: {
@ -38,6 +47,9 @@ export default registerAs('app', () => ({
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10),
},
cors: {
origins: parseCsv(process.env.BACKEND_CORS_ORIGINS),
},
auth: {
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-in-production',

View File

@ -5,7 +5,36 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
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() {
const app = await NestFactory.create(AppModule);
@ -17,7 +46,20 @@ async function bootstrap() {
app.useGlobalInterceptors(new TransformInterceptor());
app.use(cookieParser());
app.enableCors({ origin: true, credentials: true });
const configService = app.get(ConfigService);
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()
.setTitle('MoexVibe API')
@ -32,4 +74,7 @@ async function bootstrap() {
console.log(`MoexVibe API running on http://localhost:${port}/api/v1`);
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
}
bootstrap();
if (process.env.NODE_ENV !== 'test') {
void bootstrap();
}

View File

@ -0,0 +1,65 @@
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 });
});
});

View File

@ -3,6 +3,11 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { ConfigService } from '@nestjs/config';
type CacheEntry<T> = {
data: T;
cachedAt: string;
};
@Injectable()
export class CacheService {
constructor(
@ -18,6 +23,16 @@ export class CacheService {
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 {
return parts.join(':');
}
@ -31,14 +46,19 @@ export class CacheService {
const key = this.buildKey(keyPrefix, ...keyParts);
const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900);
const cached = await this.get<T>(key);
const cached = await this.get<CacheEntry<T> | T>(key);
if (cached !== undefined) {
return { data: cached, fromCache: true, cachedAt: null };
if (this.isCacheEntry<T>(cached)) {
return { data: cached.data, fromCache: true, cachedAt: cached.cachedAt };
}
return { data: cached as T, fromCache: true, cachedAt: null };
}
const data = await fetchFn();
await this.set(key, data, ttl);
const cachedAt = new Date().toISOString();
await this.set(key, { data, cachedAt }, ttl);
return { data, fromCache: false, cachedAt: new Date().toISOString() };
return { data, fromCache: false, cachedAt };
}
}

View File

@ -1,27 +1,43 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { ConfigService } from '@nestjs/config';
import { HealthService } from './health.service';
import { PrismaService } from '../prisma/prisma.service';
import configuration from '../../config/configuration';
describe('HealthService', () => {
let service: HealthService;
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 () => {
vi.clearAllMocks();
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockResolvedValue({ ok: true, status: 200 });
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration], isGlobal: true })],
providers: [
HealthService,
{ provide: PrismaService, useValue: prisma },
{ provide: ConfigService, useValue: config },
],
}).compile();
service = module.get<HealthService>(HealthService);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('returns ok when all dependencies are healthy', async () => {
prisma.$queryRaw.mockResolvedValue([{ 1: 1 }]);

View File

@ -8,6 +8,7 @@ import {
IsIn,
MaxLength,
MinLength,
IsDateString,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@ -31,7 +32,7 @@ export class AddPositionDto {
@ApiProperty({ example: 10 })
@IsInt()
@Min(0)
@Min(1)
quantity!: number;
@ApiPropertyOptional({ example: 250.5 })
@ -41,7 +42,7 @@ export class AddPositionDto {
buyPrice?: number;
@ApiPropertyOptional({ example: '2026-06-01' })
@IsString()
@IsDateString()
@IsOptional()
buyDate?: string;

View File

@ -0,0 +1,43 @@
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);
});
});

View File

@ -7,6 +7,7 @@ import {
IsArray,
IsIn,
MaxLength,
IsDateString,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
@ -24,7 +25,7 @@ const TAGS = [
export class UpdatePositionDto {
@ApiPropertyOptional({ example: 15 })
@IsInt()
@Min(0)
@Min(1)
@IsOptional()
quantity?: number;
@ -35,7 +36,7 @@ export class UpdatePositionDto {
buyPrice?: number;
@ApiPropertyOptional({ example: '2026-06-15' })
@IsString()
@IsDateString()
@IsOptional()
buyDate?: string;

View File

@ -19,6 +19,12 @@ export class BrokerAnalyticsDto {
@ApiProperty()
totalReceived!: number;
@ApiProperty()
totalFees!: number;
@ApiProperty()
totalTaxesPaid!: number;
@ApiProperty({ type: Number, nullable: true })
totalReturnPercent!: number | null;

View File

@ -7,6 +7,7 @@ import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto
import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto';
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
import { BrokerPortfolioHistoryDataDto } from './broker-portfolio-history-response.dto';
export class BrokerAccountsEnvelopeDto {
@ApiProperty({ type: [BrokerAccountResponseDto] })
@ -63,3 +64,11 @@ export class BrokerEventsEnvelopeDto {
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class BrokerPortfolioHistoryEnvelopeDto {
@ApiProperty({ type: BrokerPortfolioHistoryDataDto })
data!: BrokerPortfolioHistoryDataDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -40,4 +40,9 @@ export class BrokerOperationQueryDto {
@IsOptional()
@IsString()
state?: string;
@ApiPropertyOptional({ description: 'Comma-separated category filter: trade,income,tax,fee,transfer,other' })
@IsOptional()
@IsString()
categories?: string;
}

View File

@ -0,0 +1,24 @@
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;
}

View File

@ -4,7 +4,7 @@ import { CacheService } from '../../cache/cache.service';
describe('BrokerAccountsService', () => {
const client = {
getServiceClient: vi.fn(),
getUsersClient: vi.fn(),
callUnary: vi.fn(),
} as unknown as TBankClientService;
const cache = {
@ -23,7 +23,7 @@ describe('BrokerAccountsService', () => {
cachedAt: '2026-06-16T02:30:00.000Z',
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({ getAccounts: vi.fn() } as any);
vi.mocked(client.getUsersClient).mockReturnValue({ getAccounts: vi.fn() } as any);
vi.mocked(client.callUnary).mockResolvedValue({
accounts: [
{ id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' },

View File

@ -32,9 +32,9 @@ export class BrokerAccountsService {
}
private async fetchAccounts(): Promise<BrokerAccount[]> {
const usersClient = this.tbankClient.getServiceClient('UsersService') as any;
const usersClient = this.tbankClient.getUsersClient();
const response = await this.tbankClient.callUnary<
Record<string, string>,
{ status: string },
TBankAccountsResponse
>(
'UsersService/GetAccounts',

View File

@ -2,11 +2,15 @@ import { CacheService } from '../../cache/cache.service';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerAnalyticsService } from './broker-analytics.service';
import { PrismaService } from '../../prisma/prisma.service';
import { TBankClientService } from './tbank-client.service';
import type { TBankOperationsByCursorResponse, TBankPortfolioResponse, TBankOperationItem } from '../types/tbank-proto.types';
describe('BrokerAnalyticsService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const prisma = { brokerOperation: { findMany: vi.fn() } } as unknown as PrismaService;
const tbankClient = {
getOperationsClient: vi.fn(),
callUnary: vi.fn(),
} as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
const acc1 = {
@ -18,6 +22,8 @@ describe('BrokerAnalyticsService', () => {
accessLevel: null,
};
const mockClient = { getPortfolio: vi.fn(), getOperationsByCursor: vi.fn() };
function mockCachePassthrough() {
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
@ -28,27 +34,55 @@ describe('BrokerAnalyticsService', () => {
);
}
function makeOp(type: string, value: number, state?: string | null) {
return { type, payment: JSON.stringify({ value, currency: 'RUB' }), state } as any;
function mockPortfolio(expectedYield?: { units?: number | string; nano?: number }): TBankPortfolioResponse {
const response: TBankPortfolioResponse = { accountId: 'acc-1' };
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(() => {
vi.clearAllMocks();
vi.mocked(tbankClient.getOperationsClient).mockReturnValue(mockClient as any);
});
it('throws 404 for missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerAnalyticsService(prisma, accounts, cache);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
await expect(service.getAnalytics('missing')).rejects.toThrow(EntityNotFoundException);
});
it('returns zeros for account with no operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([]);
setupMocks([]);
const service = new BrokerAnalyticsService(prisma, accounts, cache);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data).toEqual({
@ -59,6 +93,8 @@ describe('BrokerAnalyticsService', () => {
totalCoupons: 0,
totalReceived: 0,
totalReturnPercent: null,
totalFees: 0,
totalTaxesPaid: 0,
currency: 'RUB',
});
});
@ -66,17 +102,17 @@ describe('BrokerAnalyticsService', () => {
it('aggregates deposit types correctly', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
makeOp('OPERATION_TYPE_INPUT', 1000),
makeOp('OPERATION_TYPE_INPUT_SWIFT', 500),
makeOp('OPERATION_TYPE_INP_MULTI', 200),
makeOp('OPERATION_TYPE_OVER_PLACEMENT', 300),
makeOp('OPERATION_TYPE_TRANS_IIS_BS', 100),
makeOp('OPERATION_TYPE_TRANS_BS_BS', 50),
makeOp('OPERATION_TYPE_INPUT_ACQUIRING', 150),
setupMocks([
makeItem('OPERATION_TYPE_INPUT', 1000),
makeItem('OPERATION_TYPE_INPUT_SWIFT', 500),
makeItem('OPERATION_TYPE_INP_MULTI', 200),
makeItem('OPERATION_TYPE_OVER_PLACEMENT', 300),
makeItem('OPERATION_TYPE_TRANS_IIS_BS', 100),
makeItem('OPERATION_TYPE_TRANS_BS_BS', 50),
makeItem('OPERATION_TYPE_INPUT_ACQUIRING', 150),
]);
const service = new BrokerAnalyticsService(prisma, accounts, cache);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(2300);
@ -87,15 +123,15 @@ describe('BrokerAnalyticsService', () => {
it('aggregates withdrawal types with absolute value', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
makeOp('OPERATION_TYPE_OUTPUT', -500),
makeOp('OPERATION_TYPE_OUTPUT_SWIFT', -200),
makeOp('OPERATION_TYPE_OUTPUT_ACQUIRING', -100),
makeOp('OPERATION_TYPE_OUT_MULTI', -50),
makeOp('OPERATION_TYPE_INPUT', 1000),
setupMocks([
makeItem('OPERATION_TYPE_OUTPUT', -500),
makeItem('OPERATION_TYPE_OUTPUT_SWIFT', -200),
makeItem('OPERATION_TYPE_OUTPUT_ACQUIRING', -100),
makeItem('OPERATION_TYPE_OUT_MULTI', -50),
makeItem('OPERATION_TYPE_INPUT', 1000),
]);
const service = new BrokerAnalyticsService(prisma, accounts, cache);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(1000);
@ -106,14 +142,14 @@ describe('BrokerAnalyticsService', () => {
it('aggregates dividend and coupon types', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
makeOp('OPERATION_TYPE_DIVIDEND', 300),
makeOp('OPERATION_TYPE_DIV_EXT', 150),
makeOp('OPERATION_TYPE_COUPON', 75),
makeOp('OPERATION_TYPE_COUPON', 25),
setupMocks([
makeItem('OPERATION_TYPE_DIVIDEND', 300),
makeItem('OPERATION_TYPE_DIV_EXT', 150),
makeItem('OPERATION_TYPE_COUPON', 75),
makeItem('OPERATION_TYPE_COUPON', 25),
]);
const service = new BrokerAnalyticsService(prisma, accounts, cache);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDividends).toBe(450);
@ -121,82 +157,64 @@ describe('BrokerAnalyticsService', () => {
expect(result.data.totalReceived).toBe(550);
});
it('calculates totalReturnPercent correctly', async () => {
it('uses expectedYield from portfolio for totalReturnPercent', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
makeOp('OPERATION_TYPE_INPUT', 10000),
makeOp('OPERATION_TYPE_DIVIDEND', 500),
makeOp('OPERATION_TYPE_COUPON', 200),
]);
setupMocks(
[
makeItem('OPERATION_TYPE_INPUT', 10000),
makeItem('OPERATION_TYPE_DIVIDEND', 500),
makeItem('OPERATION_TYPE_COUPON', 200),
],
mockPortfolio({ units: 7, nano: 0 }),
);
const service = new BrokerAnalyticsService(prisma, accounts, cache);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
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);
});
it('returns null totalReturnPercent when netInvested <= 0', async () => {
it('returns null totalReturnPercent when portfolio has no expectedYield', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
makeOp('OPERATION_TYPE_OUTPUT', -500),
makeOp('OPERATION_TYPE_DIVIDEND', 100),
]);
setupMocks([makeItem('OPERATION_TYPE_OUTPUT', -500)]);
const service = new BrokerAnalyticsService(prisma, accounts, cache);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.netInvested).toBe(-500);
expect(result.data.totalReturnPercent).toBeNull();
});
it('handles malformed payment JSON gracefully', async () => {
it('aggregates fee and tax categories from operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
{ type: 'OPERATION_TYPE_INPUT', payment: 'invalid-json', state: 'OPERATION_STATE_EXECUTED' },
{ type: 'OPERATION_TYPE_INPUT', payment: JSON.stringify({ value: 500, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED' },
] as any);
setupMocks([
makeItem('OPERATION_TYPE_SERVICE_FEE', -100),
makeItem('OPERATION_TYPE_BROKER_FEE', -50),
makeItem('OPERATION_TYPE_TAX', -200),
makeItem('OPERATION_TYPE_DIVIDEND_TAX', -30),
makeItem('OPERATION_TYPE_INPUT', 1000),
]);
const service = new BrokerAnalyticsService(prisma, accounts, cache);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(500);
});
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 },
});
expect(result.data.totalDeposits).toBe(1000);
expect(result.data.totalFees).toBe(150);
expect(result.data.totalTaxesPaid).toBe(230);
expect(result.data.netInvested).toBe(1000);
});
it('rounds all monetary values to 2 decimal places', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
makeOp('OPERATION_TYPE_INPUT', 100.336),
makeOp('OPERATION_TYPE_DIVIDEND', 50.789),
setupMocks([
makeItem('OPERATION_TYPE_INPUT', 100.336),
makeItem('OPERATION_TYPE_DIVIDEND', 50.789),
]);
const service = new BrokerAnalyticsService(prisma, accounts, cache);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(100.34);
@ -216,17 +234,47 @@ describe('BrokerAnalyticsService', () => {
totalCoupons: 0,
totalReceived: 0,
totalReturnPercent: null,
totalFees: 0,
totalTaxesPaid: 0,
currency: 'RUB',
},
fromCache: true,
cachedAt: '2026-06-24T10:00:00.000Z',
});
const service = new BrokerAnalyticsService(prisma, accounts, cache);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.netInvested).toBe(1000);
expect(result.fromCache).toBe(true);
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);
});
});

View File

@ -1,11 +1,14 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { Injectable, Logger } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
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([
'OPERATION_TYPE_INPUT',
@ -28,18 +31,15 @@ const DIVIDEND_TYPES = new Set(['OPERATION_TYPE_DIVIDEND', 'OPERATION_TYPE_DIV_E
const COUPON_TYPES = new Set(['OPERATION_TYPE_COUPON']);
const ANALYTICS_TYPES = new Set([
...DEPOSIT_TYPES,
...WITHDRAWAL_TYPES,
...DIVIDEND_TYPES,
...COUPON_TYPES,
]);
const MAX_FETCH_PAGES = 50;
@Injectable()
export class BrokerAnalyticsService {
private readonly logger = new Logger(BrokerAnalyticsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly accountsService: BrokerAccountsService,
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService,
) {}
@ -58,34 +58,26 @@ export class BrokerAnalyticsService {
}
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {
const operations = await this.prisma.brokerOperation.findMany({
where: {
accountId,
type: { in: Array.from(ANALYTICS_TYPES) },
payment: { not: null },
OR: [
{ state: 'OPERATION_STATE_EXECUTED' },
{ state: null },
],
},
select: { type: true, payment: true },
});
const [portfolio, allOperations] = await Promise.all([
this.fetchPortfolio(accountId),
this.fetchAllOperations(accountId),
]);
let totalDeposits = 0;
let totalWithdrawn = 0;
let totalDividends = 0;
let totalCoupons = 0;
let totalFees = 0;
let totalTaxesPaid = 0;
for (const op of operations) {
let value = 0;
try {
const payment = JSON.parse(op.payment!);
value = payment.value ?? 0;
} catch {
continue;
}
for (const op of allOperations) {
const value = op.payment?.value ?? 0;
if (DEPOSIT_TYPES.has(op.type)) {
if (op.category === 'fee') {
totalFees += Math.abs(value);
} else if (op.category === 'tax') {
totalTaxesPaid += Math.abs(value);
} else if (DEPOSIT_TYPES.has(op.type)) {
totalDeposits += value;
} else if (WITHDRAWAL_TYPES.has(op.type)) {
totalWithdrawn += Math.abs(value);
@ -98,8 +90,11 @@ export class BrokerAnalyticsService {
const netInvested = totalDeposits - totalWithdrawn;
const totalReceived = totalDividends + totalCoupons;
const totalReturnPercent =
netInvested > 0 ? Math.round((totalReceived / netInvested) * 10000) / 100 : null;
const portfolioYield = portfolio.expectedYield;
const expectedYieldPercent = portfolioYield
? Math.round((Number(portfolioYield.units ?? 0) + (portfolioYield.nano ?? 0) / 1e9) * 100) / 100
: null;
return {
totalDeposits: Math.round(totalDeposits * 100) / 100,
@ -108,8 +103,66 @@ export class BrokerAnalyticsService {
totalDividends: Math.round(totalDividends * 100) / 100,
totalCoupons: Math.round(totalCoupons * 100) / 100,
totalReceived: Math.round(totalReceived * 100) / 100,
totalReturnPercent,
totalFees: Math.round(totalFees * 100) / 100,
totalTaxesPaid: Math.round(totalTaxesPaid * 100) / 100,
totalReturnPercent: expectedYieldPercent,
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;
}
}

View File

@ -23,7 +23,7 @@ export class BrokerInstrumentsService {
}
private async fetchByUid(instrumentUid: string): Promise<TBankInstrument | null> {
const instrumentsClient = this.tbankClient.getServiceClient('InstrumentsService') as any;
const instrumentsClient = this.tbankClient.getInstrumentsClient();
const response = await this.tbankClient.callUnary<
{ idType: string; id: string },
TBankInstrumentResponse

View File

@ -6,7 +6,7 @@ import { TBankClientService } from './tbank-client.service';
describe('BrokerOperationsService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const client = { getOperationsClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
beforeEach(() => {
@ -36,7 +36,7 @@ describe('BrokerOperationsService', () => {
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({ getOperationsByCursor: vi.fn() } as any);
vi.mocked(client.getOperationsClient).mockReturnValue({ getOperationsByCursor: vi.fn() } as any);
vi.mocked(client.callUnary).mockResolvedValue({
hasNext: false,
items: [{ cursor: 'c1', brokerAccountId: 'acc-1', type: 'OPERATION_TYPE_BUY' }],

View File

@ -5,7 +5,7 @@ import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto'
import { mapOperationsPage } from '../mappers/operation.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import type { BrokerOperationsPage } from '../types/broker.types';
import type { BrokerOperation, BrokerOperationsPage } from '../types/broker.types';
import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types';
import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService } from './tbank-client.service';
@ -33,6 +33,19 @@ export class BrokerOperationsService {
'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);
}
@ -69,7 +82,7 @@ export class BrokerOperationsService {
accountId: string,
request: Record<string, unknown>,
): Promise<BrokerOperationsPage> {
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
const operationsClient = this.tbankClient.getOperationsClient();
const response = await this.tbankClient.callUnary<
Record<string, unknown>,
TBankOperationsByCursorResponse

View File

@ -0,0 +1,72 @@
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(),
};
}
}

View File

@ -8,7 +8,7 @@ import { TBankClientService } from './tbank-client.service';
describe('BrokerPortfolioService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService;
const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const client = { getOperationsClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
beforeEach(() => {
@ -38,7 +38,7 @@ describe('BrokerPortfolioService', () => {
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({
vi.mocked(client.getOperationsClient).mockReturnValue({
getPortfolio: vi.fn(),
getPositions: vi.fn(),
} as any);
@ -104,7 +104,7 @@ describe('BrokerPortfolioService', () => {
it('returns first page of positions', async () => {
mockAccount();
mockCache();
vi.mocked(client.getServiceClient).mockReturnValue({
vi.mocked(client.getOperationsClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -139,7 +139,7 @@ describe('BrokerPortfolioService', () => {
it('paginates using cursor', async () => {
mockAccount();
mockCache();
vi.mocked(client.getServiceClient).mockReturnValue({
vi.mocked(client.getOperationsClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -179,7 +179,7 @@ describe('BrokerPortfolioService', () => {
it('returns last page with hasNext=false', async () => {
mockAccount();
mockCache();
vi.mocked(client.getServiceClient).mockReturnValue({
vi.mocked(client.getOperationsClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -206,7 +206,7 @@ describe('BrokerPortfolioService', () => {
it('caches positions with cursor/limit/type in key and tbankPositionsTtl', async () => {
mockAccount();
mockCache();
vi.mocked(client.getServiceClient).mockReturnValue({
vi.mocked(client.getOperationsClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -229,7 +229,7 @@ describe('BrokerPortfolioService', () => {
it('filters by instrument type and caches with type in key', async () => {
mockAccount();
mockCache();
vi.mocked(client.getServiceClient).mockReturnValue({
vi.mocked(client.getOperationsClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -279,7 +279,7 @@ describe('BrokerPortfolioService', () => {
it('returns empty items when type filter matches nothing', async () => {
mockAccount();
mockCache();
vi.mocked(client.getServiceClient).mockReturnValue({
vi.mocked(client.getOperationsClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({

View File

@ -120,7 +120,7 @@ export class BrokerPortfolioService {
'tbank:raw-portfolio',
[accountId],
async () => {
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
const operationsClient = this.tbankClient.getOperationsClient();
return this.tbankClient.callUnary<
{ accountId: string; currency: string },
TBankPortfolioResponse
@ -139,7 +139,7 @@ export class BrokerPortfolioService {
}
private async fetchPositions(accountId: string): Promise<TBankPositionsResponse> {
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
const operationsClient = this.tbankClient.getOperationsClient();
return this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>(
'OperationsService/GetPositions',
operationsClient.getPositions.bind(operationsClient),

View File

@ -44,6 +44,16 @@ describe('TBankClientService', () => {
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', () => {
const caPath = join(mkdtempSync(join(tmpdir(), 'tbank-ca-')), 'root.pem');
writeFileSync(caPath, '-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----\n');

View File

@ -1,6 +1,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TBankNotConfiguredException, TBankApiException } from '../../../common/exceptions/tbank-api.exception';
import type {
TBankAccountsResponse,
TBankInstrumentResponse,
TBankOperationsByCursorResponse,
TBankPortfolioResponse,
TBankPositionsResponse,
} from '../types/tbank-proto.types';
import {
CallOptions,
ChannelCredentials,
@ -26,6 +33,25 @@ type GrpcUnary<TRequest, TResponse> = (
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';
@Injectable()
@ -120,6 +146,18 @@ export class TBankClientService {
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>(
label: string,
method: GrpcUnary<TRequest, TResponse>,

View File

@ -6,6 +6,7 @@ import { BrokerAnalyticsService } from './services/broker-analytics.service';
import { BrokerEventsService } from './services/broker-events.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
describe('TBankController', () => {
@ -15,6 +16,7 @@ describe('TBankController', () => {
const events = { getEvents: vi.fn() } as unknown as BrokerEventsService;
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService;
const portfolioHistory = { getHistory: vi.fn() } as unknown as BrokerPortfolioHistoryService;
beforeEach(() => {
vi.clearAllMocks();
@ -42,7 +44,7 @@ describe('TBankController', () => {
),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const response = await controller.getAccounts();
expect(response).toBeInstanceOf(ApiEnvelopePayload);
@ -54,7 +56,7 @@ describe('TBankController', () => {
it('exposes a sync trigger for durable operation history', async () => {
vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 });
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const response = await controller.syncOperations('acc-1', {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-17T00:00:00.000Z',
@ -76,13 +78,15 @@ describe('TBankController', () => {
totalCoupons: 50,
totalReceived: 200,
totalReturnPercent: 25,
totalFees: 0,
totalTaxesPaid: 0,
currency: 'RUB',
};
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce(
new ApiEnvelopePayload(analyticsData, false, null),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const response = await controller.getAnalytics('acc-1');
expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1');
@ -92,6 +96,67 @@ describe('TBankController', () => {
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 () => {
const eventsData = {
items: [],
@ -114,7 +179,7 @@ describe('TBankController', () => {
new ApiEnvelopePayload(eventsData, false, '2026-06-22T00:00:00.000Z'),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' };
const response = await controller.getEvents('acc-1', query);

View File

@ -8,6 +8,7 @@ import {
BrokerOperationSyncEnvelopeDto,
BrokerOperationsEnvelopeDto,
BrokerPortfolioEnvelopeDto,
BrokerPortfolioHistoryEnvelopeDto,
BrokerPositionsEnvelopeDto,
} from './dto/broker-envelope.dto';
import { BrokerEventsQueryDto } from './dto/broker-events-query.dto';
@ -15,10 +16,11 @@ import { BrokerPositionQueryDto } from './dto/broker-position-query.dto';
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerAnalyticsService } from './services/broker-analytics.service';
import { BrokerEventsService } from './services/broker-events.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerAnalyticsService } from './services/broker-analytics.service';
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
@ApiTags('Broker')
@ -33,6 +35,7 @@ export class TBankController {
private readonly brokerOperationsService: BrokerOperationsService,
private readonly brokerOperationSyncService: BrokerOperationSyncService,
private readonly brokerAnalyticsService: BrokerAnalyticsService,
private readonly brokerPortfolioHistoryService: BrokerPortfolioHistoryService,
) {}
@Get('accounts')
@ -81,6 +84,16 @@ export class TBankController {
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')
@ApiOperation({ summary: 'Get broker account profitability analytics' })
@ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto })

View File

@ -7,6 +7,7 @@ import { BrokerInstrumentsService } from './services/broker-instruments.service'
import { BrokerEventsService } from './services/broker-events.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
import { TBankClientService } from './services/tbank-client.service';
@ -22,6 +23,7 @@ import { TBankClientService } from './services/tbank-client.service';
BrokerOperationsService,
BrokerOperationSyncService,
BrokerAnalyticsService,
BrokerPortfolioHistoryService,
],
exports: [
TBankClientService,

View File

@ -92,9 +92,17 @@
**Response:**
```json
{
"status": "ok",
"timestamp": "2026-06-13T12:00:00.000Z",
"uptime": 1234.56
"data": {
"status": "ok",
"timestamp": "2026-06-25T12:00:00.000Z",
"uptime": 1234.56,
"checks": [
{ "name": "database", "status": "ok" },
{ "name": "moex", "status": "ok" },
{ "name": "tbank", "status": "ok" }
]
},
"meta": { "fromCache": false, "cachedAt": null }
}
```
@ -403,5 +411,5 @@ codegen types.
| `/api/v1/broker/accounts/:accountId/portfolio` | GET | Портфель счёта: позиции, cash, метаданные |
| `/api/v1/broker/accounts/:accountId/events` | GET | События и будущие выплаты (дивиденды, купоны) с фильтром по датам |
| `/api/v1/broker/accounts/:accountId/operations` | GET | История операций (cursor pagination) |
| `/api/v1/broker/accounts/:accountId/operations/refresh` | POST | Принудительная синхронизация операций из T-Bank |
| `/api/v1/broker/accounts/:accountId/operations/sync` | POST | Принудительная синхронизация операций из T-Bank |
| `/api/v1/broker/accounts/:accountId/positions` | GET | Позиции счёта (с пагинацией) |

View File

@ -25,20 +25,20 @@ flowchart TB
PrismaService["PrismaService"]
CacheService["CacheService"]
MoexClientService["MoexClientService"]
MoexHttpClient["MoexHttpClient"]
TBankClientService["TBankClientService"]
PrismaModule --> PrismaService
CacheModule --> CacheService
MoexClientModule --> MoexClientService
MoexClientModule --> MoexHttpClient
AuthModule --> PrismaService
PortfolioModule --> PrismaService
PortfolioModule --> CacheService
PortfolioModule --> MoexClientService
PortfolioModule --> MoexHttpClient
MarketModules --> CacheService
MarketModules --> MoexClientService
MarketModules --> MoexHttpClient
TBankModule --> CacheService
TBankModule --> PrismaService
@ -51,7 +51,7 @@ flowchart TB
|---|---|---|---|
| `PrismaModule` | Да | `modules/prisma/` | Prisma client для SQLite |
| `CacheModule` | Да | `modules/cache/` | In-memory cache через cache-manager |
| `MoexClientModule` | Да | `modules/moex-client/` | HTTP-клиент MOEX ISS |
| `MoexClientModule` | Нет | `modules/moex-client/` | HTTP- и domain-клиенты MOEX ISS |
| `HealthModule` | Нет | `modules/health/` | Health check endpoint |
| `AuthModule` | Нет | `modules/auth/` | JWT auth, refresh cookie, guards |
| `SecuritiesModule` | Нет | `modules/securities/` | Поиск инструментов |
@ -79,17 +79,17 @@ flowchart TB
### MoexClientModule
Глобальный HTTP-клиент для MOEX ISS.
Модуль для MOEX ISS, разделённый на `MoexHttpClient` и domain-specific клиенты.
- Rate limiter: p-queue (10 req/s по умолчанию, настраивается через `MOEX_RATE_LIMIT`)
- Circuit breaker: открывается после 5 ошибок, сбрасывается через 30s
- Все ответы нормализуются из табличного формата MOEX в доменные типы
- `MoexHttpClient` — rate limiter (p-queue, 10 req/s), circuit breaker (5 errors → 30s open), ISS JSON parsing.
- Domain clients — `MoexSecuritiesClient`, `MoexMarketDataClient`, `MoexCandlesClient`, `MoexHistoryClient`, `MoexDividendsClient`.
- Все ответы нормализуются из табличного формата MOEX в доменные типы.
### HealthModule
Проверка состояния сервиса.
- `GET /api/v1/health``{ status: 'ok', timestamp, uptime }`
- `GET /api/v1/health``{ data: { status, timestamp, uptime, checks }, meta }`
### AuthModule

View File

@ -2,9 +2,21 @@
## Обзор
`MoexClientService` (`apps/backend/src/modules/moex-client/moex-client.service.ts`) — HTTP-клиент для MOEX ISS API.
MOEX integration is split into a shared HTTP infrastructure client and focused domain clients under
`apps/backend/src/modules/moex-client/`:
## Rate limiting
- `MoexHttpClient` — request queue, rate limiting, circuit breaker, ISS JSON parsing.
- `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`:
@ -17,7 +29,7 @@ this.queue = new PQueue({
Все запросы к MOEX проходят через очередь — не более `MOEX_RATE_LIMIT` запросов в секунду.
## Circuit breaker
### Circuit breaker
Состояние: закрыт → открыт → полуоткрыт (через таймаут).
@ -30,7 +42,7 @@ private circuitErrorCount = 0;
- В открытом состоянии все запросы мгновенно падают с ошибкой `"Circuit breaker is open"`
- Через `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` (30) автоматически сбрасывается
## Метод request
### Метод request
```typescript
private async request<T>(path: string, params?: Record<string, string>): Promise<T>
@ -40,7 +52,7 @@ private async request<T>(path: string, params?: Record<string, string>): Promise
- Устанавливает `iss.meta=off` (отключает метаданные)
- Таймаут: 10s
## Разбор response
### Разбор response
MOEX возвращает данные в табличном формате:
@ -55,19 +67,17 @@ MOEX возвращает данные в табличном формате:
Метод `extractTable` преобразует это в массив объектов по колонкам.
## Доступные методы MOEX
## Domain clients
| Метод | MOEX path | Описание |
|---|---|---|
| `searchSecurities` | `/securities?q=` | Поиск инструментов |
| `getSecurityDescription` | `/securities/{secid}` | Спецификация |
| `getShareMarketData` | `/engines/stock/markets/shares/securities/{secid}` | Рыночные данные акции (board: TQBR) |
| `getBondData` | `/engines/stock/markets/bonds/securities/{secid}` | Данные облигации (board: TQCB) |
| `getBondMarketData` | `/engines/stock/markets/bonds/securities/{secid}` | Рыночные данные облигации |
| `getDividends` | `/securities/{secid}/dividends` | Дивиденды |
| `getCandles` | `/engines/{engine}/markets/{market}/securities/{secid}/candles` | Свечи |
| `getHistory` | `/engines/stock/markets/shares/securities/{secid}` | История акций |
| `getBondHistory` | `/engines/stock/markets/bonds/securities/{secid}` | История облигаций |
Каждый domain-клиент использует `MoexHttpClient` для HTTP и предоставляет свои методы:
| Client | Методы |
|---|---|
| `MoexSecuritiesClient` | `searchSecurities`, `getSecurityDescription` |
| `MoexMarketDataClient` | `getShareMarketData`, `getShareMarketDataBatch`, `getBondData`, `getBondMarketData`, `getBondPositionDataBatch` |
| `MoexCandlesClient` | `getCandles` |
| `MoexHistoryClient` | `getShareHistory`, `getBondHistory` |
| `MoexDividendsClient` | `getDividends` |
## MOEX ISS types

View File

@ -24,6 +24,7 @@
"@moex-vibe/design-system": "*",
"@mui/icons-material": "^6.5.0",
"@mui/material": "^6.5.0",
"@mui/x-date-pickers": "^7.29.4",
"@tanstack/react-query": "^5.20.0",
"@tanstack/react-router": "^1.170.16",
"@tanstack/react-table": "^8.21.3",
@ -43,7 +44,6 @@
"@biomejs/biome": "^2.5.0",
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
"@tanstack/router-devtools": "^1.167.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",

View File

@ -18,65 +18,72 @@ export function AppLayout() {
background: 'var(--color-surface)',
borderBottom: '1px solid #e0e0e0',
padding: '12px 24px',
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: 24,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
gap: 12,
}}
>
<Link
to="/"
style={{
fontSize: 20,
fontWeight: 700,
color: 'var(--color-text)',
textDecoration: 'none',
}}
>
MoexVibe
</Link>
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
<SearchBar />
<div style={{ display: 'grid', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
<Link
to="/"
style={{
fontSize: 20,
fontWeight: 700,
color: 'var(--color-text)',
textDecoration: 'none',
}}
>
MoexVibe
</Link>
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
<SearchBar />
</div>
</div>
<nav
style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}
aria-label="Основная навигация"
>
<Link
to="/portfolios"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Портфели
</Link>
<Link
to="/broker"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Брокер
</Link>
<Link
to="/screener"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Скринер
</Link>
</nav>
</div>
<Link
to="/portfolios"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Портфели
</Link>
<Link
to="/broker"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Брокер
</Link>
<Link
to="/screener"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Скринер
</Link>
<div
style={{
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 12,
flexWrap: 'wrap',
}}

View File

@ -0,0 +1,12 @@
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 },
)
}

View File

@ -10,3 +10,4 @@ export {
export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios'
export { useBrokerAccounts } from './model/useBrokerAccounts'
export { useBrokerPortfolio } from './model/useBrokerPortfolio'
export { useBrokerPortfolioHistory } from './model/useBrokerPortfolioHistory'

View File

@ -0,0 +1,14 @@
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,
})
}

View File

@ -2,11 +2,15 @@ import { useQuery } from '@tanstack/react-query'
import type { BrokerEventsData } from '@/shared/api'
import { type BrokerEventsQuery, getBrokerEvents } from '../api/brokerEventApi'
export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) {
export function useBrokerEvents(
accountId: string | undefined,
query: BrokerEventsQuery,
options: { enabled?: boolean } = {},
) {
const { from, to, types } = query
return useQuery<BrokerEventsData>({
queryKey: ['broker', 'events', accountId, from, to, types],
enabled: Boolean(accountId),
enabled: Boolean(accountId) && (options.enabled ?? true),
queryFn: async () => (await getBrokerEvents(accountId!, { from, to, types })).data,
staleTime: 300_000,
retry: 2,

View File

@ -13,6 +13,7 @@ export type BrokerOperationQuery = {
instrumentId?: string
operationTypes?: string
state?: string
categories?: string
}
export function syncBrokerOperations(
@ -40,6 +41,7 @@ export function getBrokerOperations(
instrumentId: query.instrumentId,
operationTypes: query.operationTypes,
state: query.state,
categories: query.categories,
},
)
}

View File

@ -5,10 +5,11 @@ import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOpe
export function useBrokerOperations(
accountId: string | undefined,
query: BrokerOperationQuery = {},
options: { enabled?: boolean } = {},
) {
return useQuery<BrokerOperationsPage>({
queryKey: ['broker', 'operations', accountId, query],
enabled: Boolean(accountId),
enabled: Boolean(accountId) && (options.enabled ?? true),
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
staleTime: 300_000,
retry: 2,

View File

@ -1,18 +1,11 @@
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 { 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'
import { BrokerDashboard, BrokerDashboardSkeleton } from '@/widgets/broker-dashboard'
export function BrokerAccountOverviewPage() {
const { accountId, portfolio } = useBrokerAccountContext()
const operations = useBrokerOperations(accountId, { limit: 5 })
if (portfolio.isLoading) return <BrokerOverviewSkeleton />
if (portfolio.isLoading) return <BrokerDashboardSkeleton />
if (portfolio.error || !portfolio.data) {
return (
<Text component="p" role="alert" tone="negative">
@ -21,28 +14,5 @@ export function BrokerAccountOverviewPage() {
)
}
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>
)
return <BrokerDashboard accountId={accountId} portfolio={portfolio.data} />
}

View File

@ -64,5 +64,9 @@ export type BrokerEventsSummary = components['schemas']['BrokerEventsSummaryDto'
// Broker analytics
export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto']
// Broker portfolio history
export type BrokerPortfolioHistoryPoint = components['schemas']['BrokerPortfolioHistoryPointDto']
export type BrokerPortfolioHistoryData = components['schemas']['BrokerPortfolioHistoryDataDto']
// Broker sync
export type BrokerOperationSyncResponse = components['schemas']['BrokerOperationSyncResponseDto']

View File

@ -468,6 +468,23 @@ export interface paths {
patch?: 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': {
parameters: {
query?: never
@ -510,6 +527,13 @@ export interface components {
cachedAt: string | null
fromCache: boolean
}
HealthCheckResultDto: {
/** @example prisma */
name: string
/** @enum {string} */
status: 'ok' | 'error'
error?: string | null
}
HealthResponseDto: {
/** @example ok */
status: string
@ -517,6 +541,7 @@ export interface components {
timestamp: string
/** @example 12345 */
uptime: number
checks: components['schemas']['HealthCheckResultDto'][]
}
HealthEnvelopeDto: {
data: components['schemas']['HealthResponseDto']
@ -540,13 +565,9 @@ export interface components {
user: components['schemas']['AuthUserDto']
accessToken: string
}
AuthResponseMetaDto: {
cachedAt: string | null
fromCache: boolean
}
AuthTokenResponseDto: {
data: components['schemas']['AuthTokenDataDto']
meta: components['schemas']['AuthResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
LoginDto: {
/** @example user@example.com */
@ -559,11 +580,11 @@ export interface components {
}
AuthLogoutResponseDto: {
data: components['schemas']['LogoutDataDto']
meta: components['schemas']['AuthResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
AuthProfileResponseDto: {
data: components['schemas']['AuthUserDto']
meta: components['schemas']['AuthResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
UpdateProfileDto: {
/** @example John Doe */
@ -632,13 +653,9 @@ export interface components {
pageSize: number
totalPages: number
}
ScreenerResponseMetaDto: {
cachedAt: string | null
fromCache: boolean
}
ScreenerResponseDto: {
data: components['schemas']['ScreenerResultDto']
meta: components['schemas']['ScreenerResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
StockMarketDataDto: {
/** @example 322.35 */
@ -849,10 +866,6 @@ export interface components {
data: components['schemas']['CandleItemDto'][]
meta: components['schemas']['ApiResponseMeta']
}
PortfolioResponseMetaDto: {
cachedAt: string | null
fromCache: boolean
}
PortfolioListResponseDto: {
id: number
name: string
@ -876,7 +889,7 @@ export interface components {
}
PortfolioListEnvelopeDto: {
data: components['schemas']['PortfolioListResponseDto'][]
meta: components['schemas']['PortfolioResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
CreatePortfolioDto: {
/** @example Мой портфель */
@ -904,7 +917,7 @@ export interface components {
}
PortfolioEnvelopeDto: {
data: components['schemas']['PortfolioResponseDto']
meta: components['schemas']['PortfolioResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
PositionWithPriceDto: {
id: number
@ -981,7 +994,7 @@ export interface components {
}
PortfolioDetailEnvelopeDto: {
data: components['schemas']['PortfolioDetailResponseDto']
meta: components['schemas']['PortfolioResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
PortfolioTargetsDto: {
/** @example 70 */
@ -1049,7 +1062,7 @@ export interface components {
}
PositionEnvelopeDto: {
data: components['schemas']['PositionResponseDto']
meta: components['schemas']['PortfolioResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
UpdatePositionDto: {
/** @example 15 */
@ -1082,7 +1095,7 @@ export interface components {
}
AnalyticsEnvelopeDto: {
data: components['schemas']['AnalyticsResponseDto']
meta: components['schemas']['PortfolioResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
BrokerAccountResponseDto: {
id: string
@ -1093,13 +1106,9 @@ export interface components {
openedAt: Record<string, never> | null
accessLevel: Record<string, never> | null
}
BrokerResponseMetaDto: {
cachedAt: Record<string, never> | null
fromCache: boolean
}
BrokerAccountsEnvelopeDto: {
data: components['schemas']['BrokerAccountResponseDto'][]
meta: components['schemas']['BrokerResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
BrokerPortfolioPositionCountsDto: {
shares: number
@ -1140,7 +1149,7 @@ export interface components {
}
BrokerPortfolioEnvelopeDto: {
data: components['schemas']['BrokerPortfolioResponseDto']
meta: components['schemas']['BrokerResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
BrokerPositionResponseDto: {
figi: Record<string, never> | null
@ -1167,7 +1176,7 @@ export interface components {
}
BrokerPositionsEnvelopeDto: {
data: components['schemas']['BrokerPositionsPageResponseDto']
meta: components['schemas']['BrokerResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
BrokerOperationResponseDto: {
cursor: Record<string, never> | null
@ -1203,7 +1212,7 @@ export interface components {
}
BrokerOperationsEnvelopeDto: {
data: components['schemas']['BrokerOperationsPageResponseDto']
meta: components['schemas']['BrokerResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
BrokerEventItemDto: {
id: string
@ -1248,7 +1257,21 @@ export interface components {
}
BrokerEventsEnvelopeDto: {
data: components['schemas']['BrokerEventsDataDto']
meta: components['schemas']['BrokerResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
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: {
totalDeposits: number
@ -1257,12 +1280,14 @@ export interface components {
totalDividends: number
totalCoupons: number
totalReceived: number
totalFees: number
totalTaxesPaid: number
totalReturnPercent: number | null
currency: string
}
BrokerAnalyticsEnvelopeDto: {
data: components['schemas']['BrokerAnalyticsDto']
meta: components['schemas']['BrokerResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
BrokerOperationSyncResponseDto: {
/** @example 42 */
@ -1270,7 +1295,7 @@ export interface components {
}
BrokerOperationSyncEnvelopeDto: {
data: components['schemas']['BrokerOperationSyncResponseDto']
meta: components['schemas']['BrokerResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
}
responses: never
@ -1777,7 +1802,7 @@ export interface operations {
content: {
'application/json': {
data: null
meta: components['schemas']['PortfolioResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
}
}
@ -1852,7 +1877,7 @@ export interface operations {
content: {
'application/json': {
data: null
meta: components['schemas']['PortfolioResponseMetaDto']
meta: components['schemas']['ApiResponseMeta']
}
}
}
@ -1982,6 +2007,8 @@ export interface operations {
instrumentId?: string
operationTypes?: string
state?: string
/** @description Comma-separated category filter: trade,income,tax,fee,transfer,other */
categories?: string
}
header?: never
path: {
@ -2029,6 +2056,29 @@ 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: {
parameters: {
query?: never

View File

@ -1,14 +1,16 @@
import { Heading } from '@moex-vibe/design-system'
import { Heading, Skeleton } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import type { UseQueryResult } from '@tanstack/react-query'
import { Link, useParams } from '@tanstack/react-router'
import { createContext, type ReactNode } from 'react'
import { useBrokerPortfolio } from '@/entities/broker-account'
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 = {
padding: '10px 12px',
borderRadius: 8,
padding: '10px 14px',
borderRadius: 999,
color: 'var(--color-text-secondary)',
textDecoration: 'none',
whiteSpace: 'nowrap',
@ -16,6 +18,7 @@ const baseLinkStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
fontSize: 14,
fontWeight: 600,
}
const links = [
@ -42,56 +45,107 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
return (
<BrokerAccountContext.Provider value={{ accountId, portfolio }}>
<Box sx={{ display: 'grid', gap: 3 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Heading level={1}>{portfolio.data?.account.name || 'Брокерский счёт'}</Heading>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: '1fr',
gap: 2,
'@media (min-width: 720px)': {
gridTemplateColumns: 'minmax(150px, 190px) minmax(0, 1fr)',
gap: 3,
},
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'space-between',
gap: 3,
minHeight: 72,
}}
>
<Box
component="nav"
aria-label="Разделы брокерского счёта"
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.5,
'@media (max-width: 719px)': {
flexDirection: 'row',
overflowX: 'auto',
scrollbarWidth: 'thin',
},
alignItems: 'flex-end',
gap: 2.5,
minWidth: 0,
flex: '1 1 auto',
}}
>
{links.map((link) => (
<Link
key={link.to}
to={`${basePath}${link.to}`}
style={baseLinkStyle}
activeOptions={{ exact: link.to === '' }}
activeProps={{
style: {
...baseLinkStyle,
color: 'var(--color-primary)',
<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,
},
}}
>
{link.label}
</Link>
))}
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 sx={{ minWidth: 0 }}>{children}</Box>
</Box>
<Box
component="nav"
aria-label="Разделы брокерского счёта"
sx={{
display: 'flex',
gap: 0.5,
overflowX: 'auto',
scrollbarWidth: 'thin',
pb: 0.5,
}}
>
{links.map((link) => (
<Link
key={link.to}
to={`${basePath}${link.to}`}
style={baseLinkStyle}
activeOptions={{ exact: link.to === '' }}
activeProps={{
style: {
...baseLinkStyle,
color: 'var(--color-primary)',
fontWeight: 700,
background: 'rgba(25, 118, 210, 0.1)',
},
}}
>
{link.label}
</Link>
))}
</Box>
<Box sx={{ minWidth: 0 }}>{children}</Box>
</Box>
</BrokerAccountContext.Provider>
)

View File

@ -0,0 +1,2 @@
export { BrokerDashboard } from './ui/BrokerDashboard'
export { BrokerDashboardSkeleton } from './ui/BrokerDashboardSkeleton'

View File

@ -0,0 +1,92 @@
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(',')
}

View File

@ -0,0 +1,23 @@
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 'Ожидается'
}

View File

@ -0,0 +1,115 @@
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([])
})
})

View File

@ -0,0 +1,56 @@
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 }
}

View File

@ -0,0 +1,159 @@
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,
})
})
})

View File

@ -0,0 +1,97 @@
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 }
}

View File

@ -0,0 +1,316 @@
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 },
)
})
})

View File

@ -0,0 +1,50 @@
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>
)
}

View File

@ -0,0 +1,81 @@
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>
)
}

View File

@ -0,0 +1,258 @@
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>
)
}

View File

@ -0,0 +1,83 @@
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>
)
}

View File

@ -0,0 +1,270 @@
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>
)
}

View File

@ -0,0 +1,288 @@
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>
)
}

View File

@ -0,0 +1,83 @@
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>
)
}

View File

@ -0,0 +1,273 @@
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>
)
}

View File

@ -0,0 +1,14 @@
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>
)
}

View File

@ -0,0 +1,79 @@
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>
)
}

View File

@ -0,0 +1,33 @@
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>
)
}

View File

@ -0,0 +1,208 @@
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>
)
}

View File

@ -0,0 +1,13 @@
# 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 возможностей.

View File

@ -0,0 +1,87 @@
# 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.

View File

@ -0,0 +1,169 @@
# 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`

View File

@ -0,0 +1,9 @@
# 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

View File

@ -0,0 +1,117 @@
# 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` — раздел «Технический долг — кандидат на следующую итерацию»

View File

@ -0,0 +1,23 @@
# 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.

View File

@ -0,0 +1,329 @@
# Финальный 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.

View File

@ -0,0 +1,208 @@
# Финальный редизайн обзора брокерского счёта по 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`.

View File

@ -0,0 +1,128 @@
# Финальный редизайн брокерского 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 совпадают с эталоном.

View File

@ -0,0 +1,304 @@
# План реализации редизайна обзора брокерского счёта
> **Для агентных исполнителей:** ОБЯЗАТЕЛЬНЫЙ 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.

View File

@ -0,0 +1,292 @@
# Редизайн обзора брокерского счёта в инвестиционный дашборд
Дата: 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.

View File

@ -0,0 +1,139 @@
# Редизайн обзора брокерского счёта в инвестиционный дашборд — задачи
Дата: 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`), но фактическая вёрстка в браузере не сверялась с эталоном.

View File

@ -120,21 +120,26 @@ tooling и часть архитектурных cleanup-задач. После
- `frontend-shared-boundary-cleanup` — сужение shared/public API
- `frontend-test-hygiene` — минимизация test helpers
**Открыто, не покрыто ни одной фичей (нуждается в новых задачах):**
**Открыто и перенесено в canonical architecture backlog:**
| # | Приоритет | Debt item | Риск |
|---|-----------|-----------|------|
| 1 | P0/P1 | T-Bank data isolation by user | multi-tenant data leak |
| 2 | P1 | API envelope double-wrapping | runtime-ответы не соответствуют Swagger |
| 3 | P1 | Production config & auth security hardening | дефолтные секреты, CORS, error leaking |
| 4 | P1 | Local T-Bank history read-path | история читается напрямую из T-Bank |
| 5 | P1/P2 | Session model for multiple surfaces | single-token, нет device-level сессий |
| 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 |
| 8 | P3 | Route-level lazy loading + performance budgets | 471 KB JS bundle eager, нет budgets |
| 2 | P1 | Local T-Bank history read-path | история читается напрямую из T-Bank |
| 3 | P1/P2 | Session model for multiple surfaces | single-token, нет device-level сессий |
| 4 | P2 | Reduce type unsafety (`as any`, `no-explicit-any`) | остаточная type unsafety в contract/runtime boundaries |
| 5 | P2 | Expand testing strategy (coverage thresholds, E2E) | нет coverage gates, нет Playwright smoke |
| 6 | P3 | Route-level lazy loading + performance budgets | eager delivery без явных budgets |
Эти направления больше не должны жить как независимый frontend-only backlog. Их canonical форма и
порядок ведутся в `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 «Decompose large modules» — частично выполнена через shared-boundary-cleanup, backend-декомпозиция вне scope audit-фичи
- P3 «Prepare financial types for future ledger» — перенесена в deferred, не актуальна без инициативы ledger

View File

@ -166,6 +166,17 @@ cash flow, бюджеты, аналитика, прогнозы и автома
## Frontend-платформа
### Добавить историю стоимости брокерского портфеля
- Сохранять снапшоты полной стоимости брокерского счёта, чтобы строить график динамики портфеля по
датам.
- Отдельно спроектировать backend storage/API, периодичность обновления, валюту расчёта и правила для
пропущенных дней.
- На дашборде брокерского счёта заменить временный отказ от графика на полноценный блок `Стоимость
портфеля`, когда данные истории будут доступны.
- Не реализовывать в первой версии редизайна `/broker/:accountId`: текущая задача использует только
уже доступные данные портфеля, событий, операций и аналитики.
### Перейти к Feature-Sliced Design
- Постепенно привести frontend к FSD-архитектуре с явными границами между `app`, `pages`, `widgets`,
@ -367,6 +378,12 @@ cash flow, бюджеты, аналитика, прогнозы и автома
> - Health check прокачка — проверки Prisma, MOEX, T-Bank с детальным статусом
> - RequestLoggingMiddleware — перевод на `configure()` в AppModule
> - 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,
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.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

View File

@ -24,6 +24,17 @@ 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)
Цель: дать пользователю целостный доступ к реальным брокерским счетам T-Bank.
@ -82,6 +93,8 @@ Roadmap отражает порядок продуктовой работы, н
- [x] [MVP](features/moex-vibe/spec.md) — поиск, карточки акций/облигаций, графики, дивиденды.
- [x] [Frontend debt audit and backlog](features/frontend-debt-audit/spec.md) — audit frontend-техдолга,
разделение 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-infrastructure-hardening](features/frontend-infrastructure-hardening/spec.md) —
browser mock mode, env validation, tooling consistency, contract freshness
@ -90,7 +103,7 @@ Roadmap отражает порядок продуктовой работы, н
- [x] [frontend-test-hygiene](features/frontend-test-hygiene/spec.md) — минимизация test helpers,
нормализация conventions
### [Backend Architecture Improvements](features/backend-architecture-improvements/spec.md)
### Backend Architecture Improvements
- [x] Shared envelope DTO — единый `ApiResponseMeta` вместо 6 дублирующихся классов
- [x] Screener TTL — отдельный кеш-параметр `CACHE_SCREENER_TTL` (900s)
@ -101,16 +114,22 @@ Roadmap отражает порядок продуктовой работы, н
## Кандидаты следующих фич
Ниже опубликован canonical architecture backlog после `architecture-quality-backlog`. Старые granular
заметки folded into these tracks и не должны вестись как параллельные независимые backlog-списки.
- [x] [Миграция таблиц на дизайн-систему](features/table-migration/spec.md) — DividendsTable,
ScreenerTable, SharePositionTable, BondPositionTable мигрированы на `DataTable`. Legacy `shared/ui/Table`
и `TableSkeleton` удалены.
- [ ] T-Bank data isolation and multi-tenancy (P0/P1) — изолировать данные T-Bank по пользователям,
ownership модель
- [x] API envelope runtime contract (P1) — устранить double-wrapping, унифицировать envelope
- [ ] Auth security hardening (P1) — production-секреты, CORS allowlist, error masking, rate limiting
- [ ] Local T-Bank read-path (P1) — чтение истории операций из локальной БД вместо прямого вызова T-Bank
- [ ] Session model for multiple surfaces (P1/P2) — device-level сессии, rotation, reuse detection
- [ ] Type safety hardening (P2) — включение `no-explicit-any`, устранение `as any` в gRPC/screener/tests
- [ ] Testing strategy expansion (P2) — coverage thresholds, contract tests, Playwright smoke
- [ ] Frontend delivery optimization (P3) — route-level lazy loading, performance budgets
- [ ] T-Bank data isolation and ownership boundaries (P0/P1) — изоляция T-Bank данных по пользователям,
ownership model и backend query boundary до следующих data-flow изменений
- [ ] Backend runtime and auth hardening (P1) — отдельная security/runtime policy итерация: rate limiting,
auth surface hardening, explicit production rules
- [ ] Local T-Bank read-path (P1) — локальная persisted read-модель операций/истории вместо прямого
read-through из T-Bank
- [ ] Session model for multiple surfaces (P1/P2) — device-level sessions, rotation, reuse detection,
явный session contract для нескольких клиентских поверхностей
- [ ] Contract, type-safety, and frontend delivery hardening (P2/P3) — устранение остаточного `as any`,
укрепление API/query boundaries, quality/performance gates и lazy-loading budgets
- [ ] Broker portfolio value history (P2/P3) — хранить снапшоты стоимости брокерского счёта и показать
график динамики портфеля на дашборде
- [x] Broker-events — UX доработки и смешанный календарь.

455
package-lock.json generated
View File

@ -92,6 +92,7 @@
"@moex-vibe/design-system": "*",
"@mui/icons-material": "^6.5.0",
"@mui/material": "^6.5.0",
"@mui/x-date-pickers": "^7.29.4",
"@tanstack/react-query": "^5.20.0",
"@tanstack/react-router": "^1.170.16",
"@tanstack/react-table": "^8.21.3",
@ -7426,6 +7427,92 @@
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
"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": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@ -13150,6 +13237,20 @@
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
"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": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
@ -15275,6 +15376,20 @@
"integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==",
"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": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
@ -15324,6 +15439,16 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"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": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.2.tgz",
@ -18563,6 +18688,182 @@
"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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
@ -18866,6 +19167,28 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz",
@ -19117,6 +19440,16 @@
"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": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
@ -21162,6 +21495,13 @@
"dev": true,
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
@ -23436,6 +23776,19 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -25332,6 +25685,23 @@
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
@ -25715,6 +26085,16 @@
"devOptional": true,
"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": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
@ -30332,6 +30712,34 @@
"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": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz",
@ -30645,6 +31053,33 @@
"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": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
@ -30880,6 +31315,26 @@
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",

View File

@ -59,4 +59,22 @@ describe('Chip', () => {
renderWithTheme(<Chip label="Static" />);
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');
});
});

View File

@ -1,5 +1,5 @@
import { Chip as MuiChip } from '@mui/material';
import CancelIcon from '@mui/icons-material/Cancel';
import { Chip as MuiChip } from '@mui/material';
type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'error';
@ -15,14 +15,28 @@ export interface ChipProps {
label: string;
tone?: Tone;
onDelete?: () => void;
onClick?: () => void;
selected?: boolean;
disabled?: boolean;
}
export function Chip({ label, tone = 'neutral', onDelete }: ChipProps) {
export function Chip({
label,
tone = 'neutral',
onDelete,
onClick,
selected = true,
disabled,
}: ChipProps) {
return (
<MuiChip
label={label}
color={TONE_MAP[tone]}
variant={selected ? 'filled' : 'outlined'}
onClick={onClick}
onDelete={onDelete}
disabled={disabled}
aria-pressed={onClick ? selected : undefined}
{...(onDelete ? { deleteIcon: <CancelIcon aria-label={`Remove ${label}`} /> } : {})}
/>
);