feat: migrate from react-router-dom to @tanstack/react-router (code-first)
- Create code-first route tree in src/app/routing/routeTree.tsx - Replace ProtectedRoute with beforeLoad auth guards - Add useSearchParamsCompat for URLSearchParams access - Update App.tsx, layouts, and all page/widget imports - Add frontend tooling: biome, prettier, env config - Update all tests for TanStack Router compatibility - Remove react-router-dom dependency, @tanstack/router-plugin - Consolidate biome config at root level
This commit is contained in:
parent
6d3601a8f4
commit
cfadb2adbe
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
apps/frontend/
|
||||||
@ -5,21 +5,14 @@ module.exports = {
|
|||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
ecmaFeatures: { jsx: true },
|
|
||||||
},
|
},
|
||||||
plugins: ['@typescript-eslint/eslint-plugin', 'react', 'react-hooks', 'import', '@conarti/feature-sliced'],
|
plugins: ['@conarti/feature-sliced', 'import'],
|
||||||
extends: [
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
'plugin:react/recommended',
|
|
||||||
'plugin:react-hooks/recommended',
|
|
||||||
],
|
|
||||||
root: true,
|
root: true,
|
||||||
env: {
|
env: {
|
||||||
browser: true,
|
browser: true,
|
||||||
es2020: true,
|
es2020: true,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
react: { version: 'detect' },
|
|
||||||
'import/resolver': {
|
'import/resolver': {
|
||||||
typescript: {
|
typescript: {
|
||||||
alwaysTryTypes: true,
|
alwaysTryTypes: true,
|
||||||
@ -29,63 +22,31 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
ignorePatterns: ['.eslintrc.cjs', 'vite.config.ts', 'vitest.config.ts', 'dist/'],
|
ignorePatterns: ['.eslintrc.cjs', 'vite.config.ts', 'vitest.config.ts', 'dist/'],
|
||||||
rules: {
|
rules: {
|
||||||
'no-restricted-imports': ['warn', {
|
|
||||||
paths: [{
|
|
||||||
name: '@mui/material',
|
|
||||||
importNames: [
|
|
||||||
// DS-covered: import from @moex-vibe/design-system
|
|
||||||
'Typography', 'Button', 'TextField', 'Select', 'Checkbox',
|
|
||||||
'Paper', 'Chip', 'Badge', 'Alert', 'Dialog', 'Skeleton',
|
|
||||||
'CircularProgress', 'Link', 'IconButton',
|
|
||||||
'Table', 'TableBody', 'TableCell', 'TableContainer',
|
|
||||||
'TableHead', 'TableRow', 'TableSortLabel',
|
|
||||||
'TablePagination', 'Pagination',
|
|
||||||
],
|
|
||||||
message: 'Import from @moex-vibe/design-system instead, or use Box/Stack/Grid for layout.',
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
|
||||||
'react/react-in-jsx-scope': 'off',
|
|
||||||
|
|
||||||
// FSD layer boundaries (from @conarti/eslint-plugin-feature-sliced)
|
// FSD layer boundaries (from @conarti/eslint-plugin-feature-sliced)
|
||||||
// layers-slices: catches cross-layer violations (e.g., shared→entities)
|
|
||||||
'@conarti/feature-sliced/layers-slices': ['error', {
|
'@conarti/feature-sliced/layers-slices': ['error', {
|
||||||
// allow test files and test utilities to import from any layer for mocking
|
|
||||||
ignoreInFilesPatterns: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx', '**/test/**'],
|
ignoreInFilesPatterns: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx', '**/test/**'],
|
||||||
}],
|
}],
|
||||||
// absolute-relative: false positives with @/ alias convention — disabled
|
|
||||||
'@conarti/feature-sliced/absolute-relative': 'off',
|
|
||||||
// public-api: too strict for app/ and test internals — disabled
|
|
||||||
'@conarti/feature-sliced/public-api': 'off',
|
|
||||||
|
|
||||||
// FSD layer boundaries (from import/no-restricted-paths)
|
// FSD layer boundaries (from import/no-restricted-paths)
|
||||||
// NOTE: `from` = what's being imported, `target` = the file doing the import
|
|
||||||
'import/no-restricted-paths': [
|
'import/no-restricted-paths': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
zones: [
|
zones: [
|
||||||
// shared/ cannot import from entities/, features/, widgets/, pages/, app/
|
|
||||||
{ from: `${src}/entities`, target: `${src}/shared` },
|
{ from: `${src}/entities`, target: `${src}/shared` },
|
||||||
{ from: `${src}/features`, target: `${src}/shared` },
|
{ from: `${src}/features`, target: `${src}/shared` },
|
||||||
{ from: `${src}/widgets`, target: `${src}/shared` },
|
{ from: `${src}/widgets`, target: `${src}/shared` },
|
||||||
{ from: `${src}/pages`, target: `${src}/shared` },
|
{ from: `${src}/pages`, target: `${src}/shared` },
|
||||||
{ from: `${src}/app`, target: `${src}/shared` },
|
{ from: `${src}/app`, target: `${src}/shared` },
|
||||||
// entities/ cannot import from features/, widgets/, pages/, app/
|
|
||||||
{ from: `${src}/features`, target: `${src}/entities` },
|
{ from: `${src}/features`, target: `${src}/entities` },
|
||||||
{ from: `${src}/widgets`, target: `${src}/entities` },
|
{ from: `${src}/widgets`, target: `${src}/entities` },
|
||||||
{ from: `${src}/pages`, target: `${src}/entities` },
|
{ from: `${src}/pages`, target: `${src}/entities` },
|
||||||
{ from: `${src}/app`, target: `${src}/entities` },
|
{ from: `${src}/app`, target: `${src}/entities` },
|
||||||
// features/ cannot import from widgets/, pages/, app/
|
|
||||||
{ from: `${src}/widgets`, target: `${src}/features` },
|
{ from: `${src}/widgets`, target: `${src}/features` },
|
||||||
{ from: `${src}/pages`, target: `${src}/features` },
|
{ from: `${src}/pages`, target: `${src}/features` },
|
||||||
{ from: `${src}/app`, target: `${src}/features` },
|
{ from: `${src}/app`, target: `${src}/features` },
|
||||||
// widgets/ cannot import from pages/, app/
|
|
||||||
{ from: `${src}/pages`, target: `${src}/widgets` },
|
{ from: `${src}/pages`, target: `${src}/widgets` },
|
||||||
{ from: `${src}/app`, target: `${src}/widgets` },
|
{ from: `${src}/app`, target: `${src}/widgets` },
|
||||||
// pages/ cannot import from app/
|
|
||||||
{ from: `${src}/app`, target: `${src}/pages` },
|
{ from: `${src}/app`, target: `${src}/pages` },
|
||||||
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -5,10 +5,14 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "vite build",
|
||||||
|
"typecheck": "tsc -b",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts",
|
"codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts",
|
||||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
"lint": "biome check src/",
|
||||||
|
"lint:fix": "biome check --write src/",
|
||||||
|
"format": "biome format --write src/",
|
||||||
|
"format:check": "biome format src/",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
@ -16,11 +20,12 @@
|
|||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@moex-vibe/design-system": "*",
|
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
|
"@moex-vibe/design-system": "*",
|
||||||
"@mui/icons-material": "^6.5.0",
|
"@mui/icons-material": "^6.5.0",
|
||||||
"@mui/material": "^6.5.0",
|
"@mui/material": "^6.5.0",
|
||||||
"@tanstack/react-query": "^5.20.0",
|
"@tanstack/react-query": "^5.20.0",
|
||||||
|
"@tanstack/react-router": "^1.170.16",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
@ -31,27 +36,25 @@
|
|||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"react-hook-form": "^7.80.0",
|
"react-hook-form": "^7.80.0",
|
||||||
"react-is": "^18.3.1",
|
"react-is": "^18.3.1",
|
||||||
"react-router-dom": "^6.20.0",
|
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.5.0",
|
||||||
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
|
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
|
||||||
|
"@tanstack/router-devtools": "^1.167.0",
|
||||||
|
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
||||||
"@typescript-eslint/parser": "^7.0.0",
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
"eslint": "^8.0.0",
|
"eslint": "^8.0.0",
|
||||||
"eslint-import-resolver-alias": "^1.1.2",
|
|
||||||
"eslint-import-resolver-typescript": "^4.4.5",
|
"eslint-import-resolver-typescript": "^4.4.5",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"eslint-plugin-react": "^7.34.0",
|
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"msw": "^2.14.6",
|
"msw": "^2.14.6",
|
||||||
"openapi-typescript": "^7.0.0",
|
"openapi-typescript": "^7.0.0",
|
||||||
|
|||||||
349
apps/frontend/public/mockServiceWorker.js
Normal file
349
apps/frontend/public/mockServiceWorker.js
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock Service Worker.
|
||||||
|
* @see https://github.com/mswjs/msw
|
||||||
|
* - Please do NOT modify this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PACKAGE_VERSION = '2.14.6'
|
||||||
|
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||||
|
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||||
|
const activeClientIds = new Set()
|
||||||
|
|
||||||
|
addEventListener('install', function () {
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('activate', function (event) {
|
||||||
|
event.waitUntil(self.clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('message', async function (event) {
|
||||||
|
const clientId = Reflect.get(event.source || {}, 'id')
|
||||||
|
|
||||||
|
if (!clientId || !self.clients) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await self.clients.get(clientId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
switch (event.data) {
|
||||||
|
case 'KEEPALIVE_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'KEEPALIVE_RESPONSE',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'INTEGRITY_CHECK_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||||
|
payload: {
|
||||||
|
packageVersion: PACKAGE_VERSION,
|
||||||
|
checksum: INTEGRITY_CHECKSUM,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MOCK_ACTIVATE': {
|
||||||
|
activeClientIds.add(clientId)
|
||||||
|
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'MOCKING_ENABLED',
|
||||||
|
payload: {
|
||||||
|
client: {
|
||||||
|
id: client.id,
|
||||||
|
frameType: client.frameType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CLIENT_CLOSED': {
|
||||||
|
activeClientIds.delete(clientId)
|
||||||
|
|
||||||
|
const remainingClients = allClients.filter((client) => {
|
||||||
|
return client.id !== clientId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unregister itself when there are no more clients
|
||||||
|
if (remainingClients.length === 0) {
|
||||||
|
self.registration.unregister()
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('fetch', function (event) {
|
||||||
|
const requestInterceptedAt = Date.now()
|
||||||
|
|
||||||
|
// Bypass navigation requests.
|
||||||
|
if (event.request.mode === 'navigate') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening the DevTools triggers the "only-if-cached" request
|
||||||
|
// that cannot be handled by the worker. Bypass such requests.
|
||||||
|
if (
|
||||||
|
event.request.cache === 'only-if-cached' &&
|
||||||
|
event.request.mode !== 'same-origin'
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass all requests when there are no active clients.
|
||||||
|
// Prevents the self-unregistered worked from handling requests
|
||||||
|
// after it's been terminated (still remains active until the next reload).
|
||||||
|
if (activeClientIds.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = crypto.randomUUID()
|
||||||
|
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
*/
|
||||||
|
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||||
|
const client = await resolveMainClient(event)
|
||||||
|
const requestCloneForEvents = event.request.clone()
|
||||||
|
const response = await getResponse(
|
||||||
|
event,
|
||||||
|
client,
|
||||||
|
requestId,
|
||||||
|
requestInterceptedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send back the response clone for the "response:*" life-cycle events.
|
||||||
|
// Ensure MSW is active and ready to handle the message, otherwise
|
||||||
|
// this message will pend indefinitely.
|
||||||
|
if (client && activeClientIds.has(client.id)) {
|
||||||
|
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||||
|
|
||||||
|
// Clone the response so both the client and the library could consume it.
|
||||||
|
const responseClone = response.clone()
|
||||||
|
|
||||||
|
sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: 'RESPONSE',
|
||||||
|
payload: {
|
||||||
|
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||||
|
request: {
|
||||||
|
id: requestId,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
type: responseClone.type,
|
||||||
|
status: responseClone.status,
|
||||||
|
statusText: responseClone.statusText,
|
||||||
|
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||||
|
body: responseClone.body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the main client for the given event.
|
||||||
|
* Client that issues a request doesn't necessarily equal the client
|
||||||
|
* that registered the worker. It's with the latter the worker should
|
||||||
|
* communicate with during the response resolving phase.
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @returns {Promise<Client | undefined>}
|
||||||
|
*/
|
||||||
|
async function resolveMainClient(event) {
|
||||||
|
const client = await self.clients.get(event.clientId)
|
||||||
|
|
||||||
|
if (activeClientIds.has(event.clientId)) {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client?.frameType === 'top-level') {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
return allClients
|
||||||
|
.filter((client) => {
|
||||||
|
// Get only those clients that are currently visible.
|
||||||
|
return client.visibilityState === 'visible'
|
||||||
|
})
|
||||||
|
.find((client) => {
|
||||||
|
// Find the client ID that's recorded in the
|
||||||
|
// set of clients that have registered the worker.
|
||||||
|
return activeClientIds.has(client.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {Client | undefined} client
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||||
|
// Clone the request because it might've been already used
|
||||||
|
// (i.e. its body has been read and sent to the client).
|
||||||
|
const requestClone = event.request.clone()
|
||||||
|
|
||||||
|
function passthrough() {
|
||||||
|
// Cast the request headers to a new Headers instance
|
||||||
|
// so the headers can be manipulated with.
|
||||||
|
const headers = new Headers(requestClone.headers)
|
||||||
|
|
||||||
|
// Remove the "accept" header value that marked this request as passthrough.
|
||||||
|
// This prevents request alteration and also keeps it compliant with the
|
||||||
|
// user-defined CORS policies.
|
||||||
|
const acceptHeader = headers.get('accept')
|
||||||
|
if (acceptHeader) {
|
||||||
|
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||||
|
const filteredValues = values.filter(
|
||||||
|
(value) => value !== 'msw/passthrough',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (filteredValues.length > 0) {
|
||||||
|
headers.set('accept', filteredValues.join(', '))
|
||||||
|
} else {
|
||||||
|
headers.delete('accept')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(requestClone, { headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass mocking when the client is not active.
|
||||||
|
if (!client) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass initial page load requests (i.e. static assets).
|
||||||
|
// The absence of the immediate/parent client in the map of the active clients
|
||||||
|
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||||
|
// and is not ready to handle requests.
|
||||||
|
if (!activeClientIds.has(client.id)) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the client that a request has been intercepted.
|
||||||
|
const serializedRequest = await serializeRequest(event.request)
|
||||||
|
const clientMessage = await sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: 'REQUEST',
|
||||||
|
payload: {
|
||||||
|
id: requestId,
|
||||||
|
interceptedAt: requestInterceptedAt,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[serializedRequest.body],
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (clientMessage.type) {
|
||||||
|
case 'MOCK_RESPONSE': {
|
||||||
|
return respondWithMock(clientMessage.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'PASSTHROUGH': {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Client} client
|
||||||
|
* @param {any} message
|
||||||
|
* @param {Array<Transferable>} transferrables
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
function sendToClient(client, message, transferrables = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel()
|
||||||
|
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
if (event.data && event.data.error) {
|
||||||
|
return reject(event.data.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(event.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.postMessage(message, [
|
||||||
|
channel.port2,
|
||||||
|
...transferrables.filter(Boolean),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Response} response
|
||||||
|
* @returns {Response}
|
||||||
|
*/
|
||||||
|
function respondWithMock(response) {
|
||||||
|
// Setting response status code to 0 is a no-op.
|
||||||
|
// However, when responding with a "Response.error()", the produced Response
|
||||||
|
// instance will have status code set to 0. Since it's not possible to create
|
||||||
|
// a Response instance with status code 0, handle that use-case separately.
|
||||||
|
if (response.status === 0) {
|
||||||
|
return Response.error()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockedResponse = new Response(response.body, response)
|
||||||
|
|
||||||
|
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||||
|
value: true,
|
||||||
|
enumerable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return mockedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Request} request
|
||||||
|
*/
|
||||||
|
async function serializeRequest(request) {
|
||||||
|
return {
|
||||||
|
url: request.url,
|
||||||
|
mode: request.mode,
|
||||||
|
method: request.method,
|
||||||
|
headers: Object.fromEntries(request.headers.entries()),
|
||||||
|
cache: request.cache,
|
||||||
|
credentials: request.credentials,
|
||||||
|
destination: request.destination,
|
||||||
|
integrity: request.integrity,
|
||||||
|
redirect: request.redirect,
|
||||||
|
referrer: request.referrer,
|
||||||
|
referrerPolicy: request.referrerPolicy,
|
||||||
|
body: await request.arrayBuffer(),
|
||||||
|
keepalive: request.keepalive,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +1,6 @@
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { RouterProvider } from '@tanstack/react-router'
|
||||||
import { AppRoutes } from './routing/AppRoutes';
|
import { router } from './routing/router'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return <RouterProvider router={router} />
|
||||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
|
||||||
<AppRoutes />
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { default as App } from './App';
|
export { default as App } from './App'
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
import { Link, Outlet, useNavigate } from '@tanstack/react-router'
|
||||||
import { SearchBar } from '@/widgets/search-bar';
|
import { useSession } from '@/entities/session'
|
||||||
import { useSession } from '@/entities/session';
|
import { SearchBar } from '@/widgets/search-bar'
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const { isAuthenticated, user, logout } = useSession();
|
const { isAuthenticated, user, logout } = useSession()
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate()
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await logout();
|
await logout()
|
||||||
navigate('/');
|
navigate('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -138,5 +138,5 @@ export function AppLayout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { AppLayout } from './AppLayout';
|
export { AppLayout } from './AppLayout'
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { type ReactNode } from 'react';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import type { ReactNode } from 'react'
|
||||||
import '@fontsource/inter/400.css';
|
import '@fontsource/inter/400.css'
|
||||||
import '@fontsource/inter/500.css';
|
import '@fontsource/inter/500.css'
|
||||||
import '@fontsource/inter/600.css';
|
import '@fontsource/inter/600.css'
|
||||||
import '@fontsource/inter/700.css';
|
import '@fontsource/inter/700.css'
|
||||||
import { MoexVibeThemeProvider } from '@moex-vibe/design-system/theme';
|
import { MoexVibeThemeProvider } from '@moex-vibe/design-system/theme'
|
||||||
import { SessionProvider } from './SessionProvider';
|
import { SessionProvider } from './SessionProvider'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@ -15,7 +15,7 @@ const queryClient = new QueryClient({
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
export function AppProviders({ children }: { children: ReactNode }) {
|
export function AppProviders({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@ -24,5 +24,5 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
|||||||
<SessionProvider>{children}</SessionProvider>
|
<SessionProvider>{children}</SessionProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</MoexVibeThemeProvider>
|
</MoexVibeThemeProvider>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +1,27 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { useContext } from 'react';
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import userEvent from '@testing-library/user-event'
|
||||||
import userEvent from '@testing-library/user-event';
|
import { HttpResponse, http } from 'msw'
|
||||||
import { http, HttpResponse } from 'msw';
|
import { useContext } from 'react'
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { describe, expect, it } from 'vitest'
|
||||||
import { SessionContext } from '@/entities/session';
|
import { SessionContext } from '@/entities/session'
|
||||||
import { SessionProvider } from './SessionProvider';
|
import { server } from '@/shared/lib/test/server'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { SessionProvider } from './SessionProvider'
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1'
|
||||||
|
|
||||||
function renderWithProviders(ui: React.ReactElement) {
|
function renderWithProviders(ui: React.ReactElement) {
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<SessionProvider>{ui}</SessionProvider>
|
<SessionProvider>{ui}</SessionProvider>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TestConsumer() {
|
function TestConsumer() {
|
||||||
const ctx = useContext(SessionContext);
|
const ctx = useContext(SessionContext)
|
||||||
if (!ctx) return <div>no context</div>;
|
if (!ctx) return <div>no context</div>
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<span data-testid="session">{ctx.isAuthenticated ? 'authenticated' : 'anonymous'}</span>
|
<span data-testid="session">{ctx.isAuthenticated ? 'authenticated' : 'anonymous'}</span>
|
||||||
@ -31,44 +31,44 @@ function TestConsumer() {
|
|||||||
<button onClick={() => ctx.logout()}>logout</button>
|
<button onClick={() => ctx.logout()}>logout</button>
|
||||||
<button onClick={() => ctx.updateProfile({ name: 'New' })}>updateProfile</button>
|
<button onClick={() => ctx.updateProfile({ name: 'New' })}>updateProfile</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('SessionProvider', () => {
|
describe('SessionProvider', () => {
|
||||||
it('starts unauthenticated when refresh fails', async () => {
|
it('starts unauthenticated when refresh fails', async () => {
|
||||||
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
|
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })))
|
||||||
renderWithProviders(<TestConsumer />);
|
renderWithProviders(<TestConsumer />)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('session')).toHaveTextContent('anonymous');
|
expect(screen.getByTestId('session')).toHaveTextContent('anonymous')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
it('restores session on mount when refresh succeeds', async () => {
|
it('restores session on mount when refresh succeeds', async () => {
|
||||||
renderWithProviders(<TestConsumer />);
|
renderWithProviders(<TestConsumer />)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('session')).toHaveTextContent('authenticated');
|
expect(screen.getByTestId('session')).toHaveTextContent('authenticated')
|
||||||
expect(screen.getByTestId('email')).toHaveTextContent('user@test.com');
|
expect(screen.getByTestId('email')).toHaveTextContent('user@test.com')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
it('updates state after login', async () => {
|
it('updates state after login', async () => {
|
||||||
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
|
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })))
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup()
|
||||||
renderWithProviders(<TestConsumer />);
|
renderWithProviders(<TestConsumer />)
|
||||||
await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('anonymous'));
|
await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('anonymous'))
|
||||||
await user.click(screen.getByRole('button', { name: 'login' }));
|
await user.click(screen.getByRole('button', { name: 'login' }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('session')).toHaveTextContent('authenticated');
|
expect(screen.getByTestId('session')).toHaveTextContent('authenticated')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
it('updates state after logout', async () => {
|
it('updates state after logout', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup()
|
||||||
renderWithProviders(<TestConsumer />);
|
renderWithProviders(<TestConsumer />)
|
||||||
await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('authenticated'));
|
await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('authenticated'))
|
||||||
await user.click(screen.getByRole('button', { name: 'logout' }));
|
await user.click(screen.getByRole('button', { name: 'logout' }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('session')).toHaveTextContent('anonymous');
|
expect(screen.getByTestId('session')).toHaveTextContent('anonymous')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@ -1,97 +1,97 @@
|
|||||||
import { useState, useEffect, useCallback, type ReactNode } from 'react';
|
import { type ReactNode, useCallback, useEffect, useState } from 'react'
|
||||||
import * as sessionApi from '@/entities/session';
|
import * as sessionApi from '@/entities/session'
|
||||||
import { SessionContext, type SessionContextValue } from '@/entities/session';
|
import { SessionContext, type SessionContextValue } from '@/entities/session'
|
||||||
import { configureAuth } from '@/shared/api/client';
|
|
||||||
import {
|
import {
|
||||||
setOnUnauthorized,
|
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
handleUnauthorized,
|
handleUnauthorized,
|
||||||
} from '@/entities/session/api/tokenManager';
|
setOnUnauthorized,
|
||||||
import type { UserResponse } from '@/shared/api/responses';
|
} from '@/entities/session/api/tokenManager'
|
||||||
|
import { configureKyAuth } from '@/shared/api/kyClient'
|
||||||
|
import type { UserResponse } from '@/shared/api/responses'
|
||||||
|
|
||||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<UserResponse | null>(null);
|
const [user, setUser] = useState<UserResponse | null>(null)
|
||||||
const [accessToken, setAccessTokenState] = useState<string | null>(null);
|
const [accessToken, setAccessTokenState] = useState<string | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false)
|
||||||
|
|
||||||
const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => {
|
const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => {
|
||||||
setUser(authData.user);
|
setUser(authData.user)
|
||||||
setAccessTokenState(authData.accessToken);
|
setAccessTokenState(authData.accessToken)
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
const clearSession = useCallback(() => {
|
const clearSession = useCallback(() => {
|
||||||
setUser(null);
|
setUser(null)
|
||||||
setAccessTokenState(null);
|
setAccessTokenState(null)
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async (email: string, password: string) => {
|
async (email: string, password: string) => {
|
||||||
const result = await sessionApi.login(email, password);
|
const result = await sessionApi.login(email, password)
|
||||||
updateSession(result);
|
updateSession(result)
|
||||||
},
|
},
|
||||||
[updateSession],
|
[updateSession],
|
||||||
);
|
)
|
||||||
|
|
||||||
const register = useCallback(
|
const register = useCallback(
|
||||||
async (email: string, password: string, name?: string) => {
|
async (email: string, password: string, name?: string) => {
|
||||||
const result = await sessionApi.register(email, password, name);
|
const result = await sessionApi.register(email, password, name)
|
||||||
updateSession(result);
|
updateSession(result)
|
||||||
},
|
},
|
||||||
[updateSession],
|
[updateSession],
|
||||||
);
|
)
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await sessionApi.logout();
|
await sessionApi.logout()
|
||||||
} catch {
|
} catch {
|
||||||
// ignore network errors on logout
|
// ignore network errors on logout
|
||||||
}
|
}
|
||||||
clearSession();
|
clearSession()
|
||||||
}, [clearSession]);
|
}, [clearSession])
|
||||||
|
|
||||||
const updateProfileFn = useCallback(async (data: { name?: string }) => {
|
const updateProfileFn = useCallback(async (data: { name?: string }) => {
|
||||||
const result = await sessionApi.updateProfile(data);
|
const result = await sessionApi.updateProfile(data)
|
||||||
setUser(result);
|
setUser(result)
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
// Try to restore session on mount
|
// Try to restore session on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
const result = await sessionApi.refresh();
|
const result = await sessionApi.refresh()
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
updateSession(result);
|
updateSession(result)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// No valid session
|
// No valid session
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setIsLoading(false);
|
setIsLoading(false)
|
||||||
setInitialized(true);
|
setInitialized(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false
|
||||||
};
|
}
|
||||||
}, [updateSession]);
|
}, [updateSession])
|
||||||
|
|
||||||
// Wire up auth config and auto-logout on unauthorized
|
// Wire up auth config and auto-logout on unauthorized
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
configureAuth({
|
configureKyAuth({
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
handleUnauthorized,
|
handleUnauthorized,
|
||||||
});
|
})
|
||||||
setOnUnauthorized(() => {
|
setOnUnauthorized(() => {
|
||||||
clearSession();
|
clearSession()
|
||||||
});
|
})
|
||||||
}, [clearSession]);
|
}, [clearSession])
|
||||||
|
|
||||||
if (!initialized && isLoading) {
|
if (!initialized && isLoading) {
|
||||||
return (
|
return (
|
||||||
@ -106,7 +106,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|||||||
>
|
>
|
||||||
Загрузка...
|
Загрузка...
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const value: SessionContextValue = {
|
const value: SessionContextValue = {
|
||||||
@ -118,7 +118,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|||||||
register,
|
register,
|
||||||
logout,
|
logout,
|
||||||
updateProfile: updateProfileFn,
|
updateProfile: updateProfileFn,
|
||||||
};
|
}
|
||||||
|
|
||||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
export { SessionProvider } from './SessionProvider';
|
export { AppProviders } from './AppProviders'
|
||||||
export { AppProviders } from './AppProviders';
|
export { SessionProvider } from './SessionProvider'
|
||||||
|
|||||||
@ -1,78 +0,0 @@
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
|
||||||
import { AppLayout } from '../layouts/AppLayout';
|
|
||||||
import { ProtectedRoute } from './ProtectedRoute';
|
|
||||||
import { HomePage } from '@/pages/home';
|
|
||||||
import { StockPage } from '@/pages/stock';
|
|
||||||
import { BondPage } from '@/pages/bond';
|
|
||||||
import { LoginPage } from '@/pages/login';
|
|
||||||
import { RegisterPage } from '@/pages/register';
|
|
||||||
import { ProfilePage } from '@/pages/profile';
|
|
||||||
import { PortfoliosListPage, PortfolioDetailPage } from '@/pages/portfolios';
|
|
||||||
import { ScreenerPage } from '@/pages/screener';
|
|
||||||
import { BrokerAccountsPage } from '@/pages/broker-accounts';
|
|
||||||
import { BrokerAccountLayout } from '@/widgets/broker-account-layout';
|
|
||||||
import { BrokerAccountOverviewPage } from '@/pages/broker-account';
|
|
||||||
import { BrokerEventsPage } from '@/pages/broker-events';
|
|
||||||
import { BrokerPositionsPage } from '@/pages/broker-positions';
|
|
||||||
import { BrokerOperationsPage } from '@/pages/broker-operations';
|
|
||||||
|
|
||||||
export function AppRoutes() {
|
|
||||||
return (
|
|
||||||
<Routes>
|
|
||||||
<Route element={<AppLayout />}>
|
|
||||||
<Route path="/" element={<HomePage />} />
|
|
||||||
<Route path="/stocks/:secid" element={<StockPage />} />
|
|
||||||
<Route path="/bonds/:secid" element={<BondPage />} />
|
|
||||||
<Route path="/screener" element={<ScreenerPage />} />
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
|
||||||
<Route
|
|
||||||
path="/profile"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<ProfilePage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/portfolios"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<PortfoliosListPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/portfolios/:id"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<PortfolioDetailPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/broker"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<BrokerAccountsPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/broker/:accountId"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<BrokerAccountLayout />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route index element={<BrokerAccountOverviewPage />} />
|
|
||||||
<Route path="shares" element={<BrokerPositionsPage type="share" title="Акции" />} />
|
|
||||||
<Route path="bonds" element={<BrokerPositionsPage type="bond" title="Облигации" />} />
|
|
||||||
<Route path="operations" element={<BrokerOperationsPage />} />
|
|
||||||
<Route path="events" element={<BrokerEventsPage />} />
|
|
||||||
</Route>
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
import { Navigate, useLocation } from 'react-router-dom';
|
|
||||||
import { useSession } from '@/entities/session';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
|
||||||
const { isAuthenticated, isLoading } = useSession();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 40,
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Загрузка...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname)}`} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@ -1,2 +1 @@
|
|||||||
export { AppRoutes } from './AppRoutes';
|
export { router } from './routeTree'
|
||||||
export { ProtectedRoute } from './ProtectedRoute';
|
|
||||||
|
|||||||
169
apps/frontend/src/app/routing/routeTree.tsx
Normal file
169
apps/frontend/src/app/routing/routeTree.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import {
|
||||||
|
createRootRoute,
|
||||||
|
createRoute,
|
||||||
|
createRouter,
|
||||||
|
Outlet,
|
||||||
|
redirect,
|
||||||
|
} from '@tanstack/react-router'
|
||||||
|
import { useSessionStore } from '@/entities/session'
|
||||||
|
import { BondPage } from '@/pages/bond'
|
||||||
|
import { BrokerAccountOverviewPage } from '@/pages/broker-account'
|
||||||
|
import { BrokerAccountsPage } from '@/pages/broker-accounts'
|
||||||
|
import { BrokerEventsPage } from '@/pages/broker-events'
|
||||||
|
import { BrokerOperationsPage } from '@/pages/broker-operations'
|
||||||
|
import { BrokerPositionsPage } from '@/pages/broker-positions'
|
||||||
|
import { HomePage } from '@/pages/home'
|
||||||
|
import { LoginPage } from '@/pages/login'
|
||||||
|
import { PortfolioDetailPage, PortfoliosListPage } from '@/pages/portfolios'
|
||||||
|
import { ProfilePage } from '@/pages/profile'
|
||||||
|
import { RegisterPage } from '@/pages/register'
|
||||||
|
import { ScreenerPage } from '@/pages/screener'
|
||||||
|
import { StockPage } from '@/pages/stock'
|
||||||
|
import { BrokerAccountLayout } from '@/widgets/broker-account-layout'
|
||||||
|
import { AppLayout } from '../layouts/AppLayout'
|
||||||
|
|
||||||
|
function requireAuth() {
|
||||||
|
if (!useSessionStore.getState().isAuthenticated) {
|
||||||
|
throw redirect({ to: '/login' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRoute = createRootRoute({
|
||||||
|
component: () => <AppLayout />,
|
||||||
|
})
|
||||||
|
|
||||||
|
const indexRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/',
|
||||||
|
component: HomePage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const stockRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/stocks/$secid',
|
||||||
|
component: StockPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const bondRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/bonds/$secid',
|
||||||
|
component: BondPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const screenerRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/screener',
|
||||||
|
component: ScreenerPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const loginRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/login',
|
||||||
|
component: LoginPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const registerRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/register',
|
||||||
|
component: RegisterPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const profileRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/profile',
|
||||||
|
beforeLoad: requireAuth,
|
||||||
|
component: ProfilePage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const portfoliosRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/portfolios',
|
||||||
|
beforeLoad: requireAuth,
|
||||||
|
component: PortfoliosListPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const portfolioDetailRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/portfolios/$id',
|
||||||
|
beforeLoad: requireAuth,
|
||||||
|
component: PortfolioDetailPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const brokerRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/broker',
|
||||||
|
beforeLoad: requireAuth,
|
||||||
|
component: BrokerAccountsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const brokerAccountRoot = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/broker/$accountId',
|
||||||
|
beforeLoad: requireAuth,
|
||||||
|
component: () => (
|
||||||
|
<BrokerAccountLayout>
|
||||||
|
<Outlet />
|
||||||
|
</BrokerAccountLayout>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const brokerAccountIndexRoute = createRoute({
|
||||||
|
getParentRoute: () => brokerAccountRoot,
|
||||||
|
path: '/',
|
||||||
|
component: BrokerAccountOverviewPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const brokerSharesRoute = createRoute({
|
||||||
|
getParentRoute: () => brokerAccountRoot,
|
||||||
|
path: '/shares',
|
||||||
|
component: () => <BrokerPositionsPage type="share" title="Акции" />,
|
||||||
|
})
|
||||||
|
|
||||||
|
const brokerBondsRoute = createRoute({
|
||||||
|
getParentRoute: () => brokerAccountRoot,
|
||||||
|
path: '/bonds',
|
||||||
|
component: () => <BrokerPositionsPage type="bond" title="Облигации" />,
|
||||||
|
})
|
||||||
|
|
||||||
|
const brokerOperationsRoute = createRoute({
|
||||||
|
getParentRoute: () => brokerAccountRoot,
|
||||||
|
path: '/operations',
|
||||||
|
component: BrokerOperationsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const brokerEventsRoute = createRoute({
|
||||||
|
getParentRoute: () => brokerAccountRoot,
|
||||||
|
path: '/events',
|
||||||
|
component: BrokerEventsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const routeTree = rootRoute.addChildren([
|
||||||
|
indexRoute,
|
||||||
|
stockRoute,
|
||||||
|
bondRoute,
|
||||||
|
screenerRoute,
|
||||||
|
loginRoute,
|
||||||
|
registerRoute,
|
||||||
|
profileRoute,
|
||||||
|
portfoliosRoute,
|
||||||
|
portfolioDetailRoute,
|
||||||
|
brokerRoute,
|
||||||
|
brokerAccountRoot.addChildren([
|
||||||
|
brokerAccountIndexRoute,
|
||||||
|
brokerSharesRoute,
|
||||||
|
brokerBondsRoute,
|
||||||
|
brokerOperationsRoute,
|
||||||
|
brokerEventsRoute,
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
})
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/frontend/src/app/routing/router.ts
Normal file
1
apps/frontend/src/app/routing/router.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { router } from './routeTree.tsx'
|
||||||
@ -1,22 +1,20 @@
|
|||||||
import { request } from '@/shared/api/client';
|
import { request } from '@/shared/api/kyClient'
|
||||||
import type {
|
import type {
|
||||||
ApiResponseMeta,
|
ApiResponseMeta,
|
||||||
BondResponse,
|
|
||||||
BondMarketData,
|
|
||||||
BondHistoryItem,
|
BondHistoryItem,
|
||||||
|
BondMarketData,
|
||||||
|
BondResponse,
|
||||||
CandleItem,
|
CandleItem,
|
||||||
} from '@/shared/api/responses';
|
} from '@/shared/api/responses'
|
||||||
|
|
||||||
export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> {
|
export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> {
|
||||||
return request<BondResponse>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`);
|
return request<BondResponse>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBondMarketData(
|
export function getBondMarketData(
|
||||||
secid: string,
|
secid: string,
|
||||||
): Promise<{ data: BondMarketData; meta: ApiResponseMeta }> {
|
): Promise<{ data: BondMarketData; meta: ApiResponseMeta }> {
|
||||||
return request<BondMarketData>(
|
return request<BondMarketData>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`)
|
||||||
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBondHistory(
|
export function getBondHistory(
|
||||||
@ -27,7 +25,7 @@ export function getBondHistory(
|
|||||||
return request<BondHistoryItem[]>(
|
return request<BondHistoryItem[]>(
|
||||||
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/history`,
|
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/history`,
|
||||||
{ from, till },
|
{ from, till },
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBondCandles(
|
export function getBondCandles(
|
||||||
@ -40,5 +38,5 @@ export function getBondCandles(
|
|||||||
interval,
|
interval,
|
||||||
from,
|
from,
|
||||||
till,
|
till,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
export { useBond } from './model/useBond';
|
export { getBond, getBondCandles, getBondHistory, getBondMarketData } from './api/bondApi'
|
||||||
export { useBondCandles } from './model/useBondCandles';
|
export { useBond } from './model/useBond'
|
||||||
export { getBond, getBondMarketData, getBondHistory, getBondCandles } from './api/bondApi';
|
export { useBondCandles } from './model/useBondCandles'
|
||||||
|
|||||||
@ -1,26 +1,26 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import type { ReactNode } from 'react'
|
||||||
import { useBond } from './useBond';
|
import { describe, expect, it } from 'vitest'
|
||||||
import { type ReactNode } from 'react';
|
import { useBond } from './useBond'
|
||||||
|
|
||||||
function createWrapper() {
|
function createWrapper() {
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('useBond', () => {
|
describe('useBond', () => {
|
||||||
it('returns bond data', async () => {
|
it('returns bond data', async () => {
|
||||||
const { result } = renderHook(() => useBond('SU26238RMFS5'), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useBond('SU26238RMFS5'), { wrapper: createWrapper() })
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
expect(result.current.data?.shortName).toBe('ОФЗ 26238');
|
expect(result.current.data?.shortName).toBe('ОФЗ 26238')
|
||||||
expect(result.current.data?.marketData.price).toBe(98.5);
|
expect(result.current.data?.marketData.price).toBe(98.5)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('returns error on 404', async () => {
|
it('returns error on 404', async () => {
|
||||||
const { result } = renderHook(() => useBond('NOTFOUND'), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useBond('NOTFOUND'), { wrapper: createWrapper() })
|
||||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { getBond } from '../api/bondApi';
|
import type { BondResponse } from '@/shared/api/responses'
|
||||||
import type { BondResponse } from '@/shared/api/responses';
|
import { getBond } from '../api/bondApi'
|
||||||
|
|
||||||
export function useBond(secid: string) {
|
export function useBond(secid: string) {
|
||||||
return useQuery<BondResponse>({
|
return useQuery<BondResponse>({
|
||||||
queryKey: ['bond', secid],
|
queryKey: ['bond', secid],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await getBond(secid);
|
const res = await getBond(secid)
|
||||||
return res.data;
|
return res.data
|
||||||
},
|
},
|
||||||
staleTime: 900_000,
|
staleTime: 900_000,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { HttpResponse, http } from 'msw'
|
||||||
import { http, HttpResponse } from 'msw';
|
import type { ReactNode } from 'react'
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { describe, expect, it } from 'vitest'
|
||||||
import { useBondCandles } from './useBondCandles';
|
import { server } from '@/shared/lib/test/server'
|
||||||
import { type ReactNode } from 'react';
|
import { useBondCandles } from './useBondCandles'
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1'
|
||||||
|
|
||||||
function createWrapper() {
|
function createWrapper() {
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('useBondCandles', () => {
|
describe('useBondCandles', () => {
|
||||||
@ -20,24 +20,24 @@ describe('useBondCandles', () => {
|
|||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'),
|
() => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'),
|
||||||
{ wrapper: createWrapper() },
|
{ wrapper: createWrapper() },
|
||||||
);
|
)
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
expect(result.current.data).toHaveLength(2);
|
expect(result.current.data).toHaveLength(2)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('returns empty array when no candles', async () => {
|
it('returns empty array when no candles', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`${API}/securities/bonds/:secid/candles`, () => {
|
http.get(`${API}/securities/bonds/:secid/candles`, () => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
||||||
});
|
})
|
||||||
}),
|
}),
|
||||||
);
|
)
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'),
|
() => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'),
|
||||||
{ wrapper: createWrapper() },
|
{ wrapper: createWrapper() },
|
||||||
);
|
)
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
expect(result.current.data).toEqual([]);
|
expect(result.current.data).toEqual([])
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { getBondCandles } from '../api/bondApi';
|
import type { CandleItem } from '@/shared/api/responses'
|
||||||
import type { CandleItem } from '@/shared/api/responses';
|
import { getBondCandles } from '../api/bondApi'
|
||||||
|
|
||||||
export function useBondCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {
|
export function useBondCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {
|
||||||
return useQuery<CandleItem[]>({
|
return useQuery<CandleItem[]>({
|
||||||
queryKey: ['bondCandles', secid, interval, from, till],
|
queryKey: ['bondCandles', secid, interval, from, till],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await getBondCandles(secid, interval, from, till);
|
const res = await getBondCandles(secid, interval, from, till)
|
||||||
return res.data;
|
return res.data
|
||||||
},
|
},
|
||||||
staleTime: 3600_000,
|
staleTime: 3600_000,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,28 +1,28 @@
|
|||||||
import { request } from '@/shared/api/client';
|
import { request } from '@/shared/api/kyClient'
|
||||||
import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api/responses';
|
import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api/responses'
|
||||||
|
|
||||||
export type BrokerOperationQuery = {
|
export type BrokerOperationQuery = {
|
||||||
from?: string;
|
from?: string
|
||||||
to?: string;
|
to?: string
|
||||||
cursor?: string;
|
cursor?: string
|
||||||
limit?: number;
|
limit?: number
|
||||||
instrumentId?: string;
|
instrumentId?: string
|
||||||
operationTypes?: string;
|
operationTypes?: string
|
||||||
state?: string;
|
state?: string
|
||||||
};
|
}
|
||||||
|
|
||||||
export function getBrokerAccounts(): Promise<{
|
export function getBrokerAccounts(): Promise<{
|
||||||
data: BrokerAccount[];
|
data: BrokerAccount[]
|
||||||
meta: ApiResponseMeta;
|
meta: ApiResponseMeta
|
||||||
}> {
|
}> {
|
||||||
return request<BrokerAccount[]>('/api/v1/broker/accounts');
|
return request<BrokerAccount[]>('/api/v1/broker/accounts')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBrokerPortfolio(accountId: string): Promise<{
|
export function getBrokerPortfolio(accountId: string): Promise<{
|
||||||
data: BrokerPortfolio;
|
data: BrokerPortfolio
|
||||||
meta: ApiResponseMeta;
|
meta: ApiResponseMeta
|
||||||
}> {
|
}> {
|
||||||
return request<BrokerPortfolio>(
|
return request<BrokerPortfolio>(
|
||||||
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`,
|
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
export { useBrokerAccounts } from './model/useBrokerAccounts';
|
export {
|
||||||
export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios';
|
type BrokerOperationQuery,
|
||||||
export { useBrokerPortfolio } from './model/useBrokerPortfolio';
|
getBrokerAccounts,
|
||||||
|
getBrokerPortfolio,
|
||||||
|
} from './api/brokerAccountApi'
|
||||||
export {
|
export {
|
||||||
aggregateBrokerAccounts,
|
aggregateBrokerAccounts,
|
||||||
type BrokerAccountsAggregate,
|
type BrokerAccountsAggregate,
|
||||||
} from './model/brokerAccountsOverview';
|
} from './model/brokerAccountsOverview'
|
||||||
export {
|
export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios'
|
||||||
getBrokerAccounts,
|
export { useBrokerAccounts } from './model/useBrokerAccounts'
|
||||||
getBrokerPortfolio,
|
export { useBrokerPortfolio } from './model/useBrokerPortfolio'
|
||||||
type BrokerOperationQuery,
|
|
||||||
} from './api/brokerAccountApi';
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { BrokerPortfolio } from '@/shared/api/responses';
|
import type { BrokerPortfolio } from '@/shared/api/responses'
|
||||||
import { aggregateBrokerAccounts } from '../model/brokerAccountsOverview';
|
import { aggregateBrokerAccounts } from '../model/brokerAccountsOverview'
|
||||||
|
|
||||||
function portfolio(
|
function portfolio(
|
||||||
id: string,
|
id: string,
|
||||||
@ -38,7 +38,7 @@ function portfolio(
|
|||||||
cash: [{ currency, units: '0', nano: 0, value: cash }],
|
cash: [{ currency, units: '0', nano: 0, value: cash }],
|
||||||
blockedCash: [],
|
blockedCash: [],
|
||||||
asOf: '2026-06-19T10:00:00.000Z',
|
asOf: '2026-06-19T10:00:00.000Z',
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('aggregateBrokerAccounts', () => {
|
describe('aggregateBrokerAccounts', () => {
|
||||||
@ -46,7 +46,7 @@ describe('aggregateBrokerAccounts', () => {
|
|||||||
const result = aggregateBrokerAccounts([
|
const result = aggregateBrokerAccounts([
|
||||||
portfolio('a', 'RUB', 1_100, 100, 200),
|
portfolio('a', 'RUB', 1_100, 100, 200),
|
||||||
portfolio('b', 'RUB', 2_200, 200, 300),
|
portfolio('b', 'RUB', 2_200, 200, 300),
|
||||||
]);
|
])
|
||||||
|
|
||||||
expect(result.portfolios).toEqual([
|
expect(result.portfolios).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@ -56,44 +56,44 @@ describe('aggregateBrokerAccounts', () => {
|
|||||||
dailyPercent: 10,
|
dailyPercent: 10,
|
||||||
allocation: { shares: 1_650, bonds: 990, etf: 0, cash: 660, other: 0 },
|
allocation: { shares: 1_650, bonds: 990, etf: 0, cash: 660, other: 0 },
|
||||||
}),
|
}),
|
||||||
]);
|
])
|
||||||
expect(result.cash).toEqual([{ currency: 'RUB', value: 500 }]);
|
expect(result.cash).toEqual([{ currency: 'RUB', value: 500 }])
|
||||||
});
|
})
|
||||||
|
|
||||||
it('keeps different currencies separate', () => {
|
it('keeps different currencies separate', () => {
|
||||||
const result = aggregateBrokerAccounts([
|
const result = aggregateBrokerAccounts([
|
||||||
portfolio('rub', 'RUB', 1_100, 100, 200),
|
portfolio('rub', 'RUB', 1_100, 100, 200),
|
||||||
portfolio('usd', 'USD', 550, 50, 25),
|
portfolio('usd', 'USD', 550, 50, 25),
|
||||||
]);
|
])
|
||||||
|
|
||||||
expect(result.portfolios.map(({ currency, total }) => ({ currency, total }))).toEqual([
|
expect(result.portfolios.map(({ currency, total }) => ({ currency, total }))).toEqual([
|
||||||
{ currency: 'RUB', total: 1_100 },
|
{ currency: 'RUB', total: 1_100 },
|
||||||
{ currency: 'USD', total: 550 },
|
{ currency: 'USD', total: 550 },
|
||||||
]);
|
])
|
||||||
});
|
})
|
||||||
|
|
||||||
it('does not expose a daily percent when one account lacks daily data', () => {
|
it('does not expose a daily percent when one account lacks daily data', () => {
|
||||||
const result = aggregateBrokerAccounts([
|
const result = aggregateBrokerAccounts([
|
||||||
portfolio('a', 'RUB', 1_100, 100, 200),
|
portfolio('a', 'RUB', 1_100, 100, 200),
|
||||||
portfolio('b', 'RUB', 2_000, null, 300),
|
portfolio('b', 'RUB', 2_000, null, 300),
|
||||||
]);
|
])
|
||||||
|
|
||||||
expect(result.portfolios[0]).toMatchObject({ daily: null, dailyPercent: null });
|
expect(result.portfolios[0]).toMatchObject({ daily: null, dailyPercent: null })
|
||||||
});
|
})
|
||||||
|
|
||||||
it('does not expose a daily percent when start of day is non-positive', () => {
|
it('does not expose a daily percent when start of day is non-positive', () => {
|
||||||
const result = aggregateBrokerAccounts([portfolio('a', 'RUB', 100, 100, 20)]);
|
const result = aggregateBrokerAccounts([portfolio('a', 'RUB', 100, 100, 20)])
|
||||||
|
|
||||||
expect(result.portfolios[0]).toMatchObject({ daily: 100, dailyPercent: null });
|
expect(result.portfolios[0]).toMatchObject({ daily: 100, dailyPercent: null })
|
||||||
});
|
})
|
||||||
|
|
||||||
it('clamps negative residual other allocation to zero', () => {
|
it('clamps negative residual other allocation to zero', () => {
|
||||||
const overAllocated = portfolio('a', 'RUB', 1_000, 50, 100);
|
const overAllocated = portfolio('a', 'RUB', 1_000, 50, 100)
|
||||||
overAllocated.totals.shares!.value = 700;
|
overAllocated.totals.shares!.value = 700
|
||||||
overAllocated.totals.bonds!.value = 400;
|
overAllocated.totals.bonds!.value = 400
|
||||||
overAllocated.totals.currencies!.value = 100;
|
overAllocated.totals.currencies!.value = 100
|
||||||
|
|
||||||
const result = aggregateBrokerAccounts([overAllocated]);
|
const result = aggregateBrokerAccounts([overAllocated])
|
||||||
|
|
||||||
expect(result.portfolios[0].allocation).toEqual({
|
expect(result.portfolios[0].allocation).toEqual({
|
||||||
shares: 700,
|
shares: 700,
|
||||||
@ -101,27 +101,27 @@ describe('aggregateBrokerAccounts', () => {
|
|||||||
etf: 0,
|
etf: 0,
|
||||||
cash: 100,
|
cash: 100,
|
||||||
other: 0,
|
other: 0,
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
it('returns empty summaries for empty or unsupported portfolios', () => {
|
it('returns empty summaries for empty or unsupported portfolios', () => {
|
||||||
const missingTotal = portfolio('a', 'RUB', 1_000, 50, 100);
|
const missingTotal = portfolio('a', 'RUB', 1_000, 50, 100)
|
||||||
missingTotal.totals.portfolio = null;
|
missingTotal.totals.portfolio = null
|
||||||
|
|
||||||
expect(aggregateBrokerAccounts([])).toEqual({ portfolios: [], cash: [] });
|
expect(aggregateBrokerAccounts([])).toEqual({ portfolios: [], cash: [] })
|
||||||
expect(aggregateBrokerAccounts([missingTotal])).toEqual({
|
expect(aggregateBrokerAccounts([missingTotal])).toEqual({
|
||||||
portfolios: [],
|
portfolios: [],
|
||||||
cash: [{ currency: 'RUB', value: 100 }],
|
cash: [{ currency: 'RUB', value: 100 }],
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
it('groups cash separately by currency', () => {
|
it('groups cash separately by currency', () => {
|
||||||
const mixedCash = portfolio('a', 'RUB', 1_000, 50, 100);
|
const mixedCash = portfolio('a', 'RUB', 1_000, 50, 100)
|
||||||
mixedCash.cash.push({ currency: 'USD', units: '0', nano: 0, value: 25 });
|
mixedCash.cash.push({ currency: 'USD', units: '0', nano: 0, value: 25 })
|
||||||
|
|
||||||
expect(aggregateBrokerAccounts([mixedCash]).cash).toEqual([
|
expect(aggregateBrokerAccounts([mixedCash]).cash).toEqual([
|
||||||
{ currency: 'RUB', value: 100 },
|
{ currency: 'RUB', value: 100 },
|
||||||
{ currency: 'USD', value: 25 },
|
{ currency: 'USD', value: 25 },
|
||||||
]);
|
])
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@ -1,74 +1,74 @@
|
|||||||
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
|
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'
|
||||||
|
|
||||||
export interface BrokerCurrencyAllocationSummary {
|
export interface BrokerCurrencyAllocationSummary {
|
||||||
shares: number;
|
shares: number
|
||||||
bonds: number;
|
bonds: number
|
||||||
etf: number;
|
etf: number
|
||||||
cash: number;
|
cash: number
|
||||||
other: number;
|
other: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrokerCurrencyPortfolioSummary {
|
export interface BrokerCurrencyPortfolioSummary {
|
||||||
currency: string;
|
currency: string
|
||||||
total: number;
|
total: number
|
||||||
daily: number | null;
|
daily: number | null
|
||||||
dailyPercent: number | null;
|
dailyPercent: number | null
|
||||||
allocation: BrokerCurrencyAllocationSummary;
|
allocation: BrokerCurrencyAllocationSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrokerCurrencyCashSummary {
|
export interface BrokerCurrencyCashSummary {
|
||||||
currency: string;
|
currency: string
|
||||||
value: number;
|
value: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrokerAccountsAggregate {
|
export interface BrokerAccountsAggregate {
|
||||||
portfolios: BrokerCurrencyPortfolioSummary[];
|
portfolios: BrokerCurrencyPortfolioSummary[]
|
||||||
cash: BrokerCurrencyCashSummary[];
|
cash: BrokerCurrencyCashSummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MutableCurrencySummary {
|
interface MutableCurrencySummary {
|
||||||
currency: string;
|
currency: string
|
||||||
total: number;
|
total: number
|
||||||
daily: number | null;
|
daily: number | null
|
||||||
dailyComparable: boolean;
|
dailyComparable: boolean
|
||||||
allocation: BrokerCurrencyAllocationSummary;
|
allocation: BrokerCurrencyAllocationSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
function moneyValue(money: BrokerMoney | null | undefined): number {
|
function moneyValue(money: BrokerMoney | null | undefined): number {
|
||||||
return money?.value ?? 0;
|
return money?.value ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
export function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string {
|
export function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string {
|
||||||
return type === 'iis' ? 'ИИС' : 'Брокерский счёт';
|
return type === 'iis' ? 'ИИС' : 'Брокерский счёт'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAccountsAggregate {
|
export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAccountsAggregate {
|
||||||
const portfolioSummaries = new Map<string, MutableCurrencySummary>();
|
const portfolioSummaries = new Map<string, MutableCurrencySummary>()
|
||||||
const cashSummaries = new Map<string, BrokerCurrencyCashSummary>();
|
const cashSummaries = new Map<string, BrokerCurrencyCashSummary>()
|
||||||
|
|
||||||
for (const portfolio of portfolios) {
|
for (const portfolio of portfolios) {
|
||||||
for (const cash of portfolio.cash) {
|
for (const cash of portfolio.cash) {
|
||||||
if (!cash.currency) {
|
if (!cash.currency) {
|
||||||
continue;
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingCash = cashSummaries.get(cash.currency);
|
const existingCash = cashSummaries.get(cash.currency)
|
||||||
|
|
||||||
if (existingCash) {
|
if (existingCash) {
|
||||||
existingCash.value += cash.value;
|
existingCash.value += cash.value
|
||||||
} else {
|
} else {
|
||||||
cashSummaries.set(cash.currency, { currency: cash.currency, value: cash.value });
|
cashSummaries.set(cash.currency, { currency: cash.currency, value: cash.value })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalMoney = portfolio.totals.portfolio;
|
const totalMoney = portfolio.totals.portfolio
|
||||||
const currency = totalMoney?.currency;
|
const currency = totalMoney?.currency
|
||||||
|
|
||||||
if (!totalMoney || !currency) {
|
if (!totalMoney || !currency) {
|
||||||
continue;
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSummary = portfolioSummaries.get(currency);
|
const existingSummary = portfolioSummaries.get(currency)
|
||||||
const summary =
|
const summary =
|
||||||
existingSummary ??
|
existingSummary ??
|
||||||
({
|
({
|
||||||
@ -77,45 +77,43 @@ export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAc
|
|||||||
daily: 0,
|
daily: 0,
|
||||||
dailyComparable: true,
|
dailyComparable: true,
|
||||||
allocation: { shares: 0, bonds: 0, etf: 0, cash: 0, other: 0 },
|
allocation: { shares: 0, bonds: 0, etf: 0, cash: 0, other: 0 },
|
||||||
} satisfies MutableCurrencySummary);
|
} satisfies MutableCurrencySummary)
|
||||||
|
|
||||||
const total = totalMoney.value;
|
const total = totalMoney.value
|
||||||
const shares = moneyValue(portfolio.totals.shares);
|
const shares = moneyValue(portfolio.totals.shares)
|
||||||
const bonds = moneyValue(portfolio.totals.bonds);
|
const bonds = moneyValue(portfolio.totals.bonds)
|
||||||
const etf = moneyValue(portfolio.totals.etf);
|
const etf = moneyValue(portfolio.totals.etf)
|
||||||
const cash = moneyValue(portfolio.totals.currencies);
|
const cash = moneyValue(portfolio.totals.currencies)
|
||||||
const other = Math.max(0, total - shares - bonds - etf - cash);
|
const other = Math.max(0, total - shares - bonds - etf - cash)
|
||||||
|
|
||||||
summary.total += total;
|
summary.total += total
|
||||||
summary.allocation.shares += shares;
|
summary.allocation.shares += shares
|
||||||
summary.allocation.bonds += bonds;
|
summary.allocation.bonds += bonds
|
||||||
summary.allocation.etf += etf;
|
summary.allocation.etf += etf
|
||||||
summary.allocation.cash += cash;
|
summary.allocation.cash += cash
|
||||||
summary.allocation.other += other;
|
summary.allocation.other += other
|
||||||
|
|
||||||
const dailyMoney = portfolio.yields.daily;
|
const dailyMoney = portfolio.yields.daily
|
||||||
const comparableDaily = dailyMoney && dailyMoney.currency === currency;
|
const comparableDaily = dailyMoney && dailyMoney.currency === currency
|
||||||
|
|
||||||
if (!comparableDaily) {
|
if (!comparableDaily) {
|
||||||
summary.daily = null;
|
summary.daily = null
|
||||||
summary.dailyComparable = false;
|
summary.dailyComparable = false
|
||||||
} else if (summary.dailyComparable) {
|
} else if (summary.dailyComparable) {
|
||||||
summary.daily = (summary.daily ?? 0) + dailyMoney.value;
|
summary.daily = (summary.daily ?? 0) + dailyMoney.value
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existingSummary) {
|
if (!existingSummary) {
|
||||||
portfolioSummaries.set(currency, summary);
|
portfolioSummaries.set(currency, summary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
portfolios: Array.from(portfolioSummaries.values()).map((summary) => {
|
portfolios: Array.from(portfolioSummaries.values()).map((summary) => {
|
||||||
const daily = summary.dailyComparable ? summary.daily : null;
|
const daily = summary.dailyComparable ? summary.daily : null
|
||||||
const startOfDay = daily === null ? null : summary.total - daily;
|
const startOfDay = daily === null ? null : summary.total - daily
|
||||||
const dailyPercent =
|
const dailyPercent =
|
||||||
daily === null || startOfDay === null || startOfDay <= 0
|
daily === null || startOfDay === null || startOfDay <= 0 ? null : (daily / startOfDay) * 100
|
||||||
? null
|
|
||||||
: (daily / startOfDay) * 100;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currency: summary.currency,
|
currency: summary.currency,
|
||||||
@ -123,8 +121,8 @@ export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAc
|
|||||||
daily,
|
daily,
|
||||||
dailyPercent,
|
dailyPercent,
|
||||||
allocation: summary.allocation,
|
allocation: summary.allocation,
|
||||||
};
|
}
|
||||||
}),
|
}),
|
||||||
cash: Array.from(cashSummaries.values()),
|
cash: Array.from(cashSummaries.values()),
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useQueries } from '@tanstack/react-query';
|
import { useQueries } from '@tanstack/react-query'
|
||||||
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses';
|
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses'
|
||||||
import { getBrokerPortfolio } from '../api/brokerAccountApi';
|
import { getBrokerPortfolio } from '../api/brokerAccountApi'
|
||||||
|
|
||||||
export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) {
|
export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) {
|
||||||
const queries = useQueries({
|
const queries = useQueries({
|
||||||
@ -11,7 +11,7 @@ export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) {
|
|||||||
retry: 2,
|
retry: 2,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
})),
|
})),
|
||||||
});
|
})
|
||||||
|
|
||||||
return accounts.map((account, index) => ({ account, query: queries[index] }));
|
return accounts.map((account, index) => ({ account, query: queries[index] }))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import type { BrokerAccount } from '@/shared/api/responses';
|
import type { BrokerAccount } from '@/shared/api/responses'
|
||||||
import { getBrokerAccounts } from '../api/brokerAccountApi';
|
import { getBrokerAccounts } from '../api/brokerAccountApi'
|
||||||
|
|
||||||
export function useBrokerAccounts() {
|
export function useBrokerAccounts() {
|
||||||
return useQuery<BrokerAccount[]>({
|
return useQuery<BrokerAccount[]>({
|
||||||
@ -9,5 +9,5 @@ export function useBrokerAccounts() {
|
|||||||
staleTime: 3_600_000,
|
staleTime: 3_600_000,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import type { BrokerPortfolio } from '@/shared/api/responses';
|
import type { BrokerPortfolio } from '@/shared/api/responses'
|
||||||
import { getBrokerPortfolio } from '../api/brokerAccountApi';
|
import { getBrokerPortfolio } from '../api/brokerAccountApi'
|
||||||
|
|
||||||
export function useBrokerPortfolio(accountId: string | undefined) {
|
export function useBrokerPortfolio(accountId: string | undefined) {
|
||||||
return useQuery<BrokerPortfolio>({
|
return useQuery<BrokerPortfolio>({
|
||||||
@ -10,5 +10,5 @@ export function useBrokerPortfolio(accountId: string | undefined) {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { request } from '@/shared/api/client';
|
import { request } from '@/shared/api/kyClient'
|
||||||
import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api/responses';
|
import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api/responses'
|
||||||
|
|
||||||
export type BrokerEventsQuery = {
|
export type BrokerEventsQuery = {
|
||||||
from: string;
|
from: string
|
||||||
to: string;
|
to: string
|
||||||
types?: string;
|
types?: string
|
||||||
};
|
}
|
||||||
|
|
||||||
export function getBrokerEvents(
|
export function getBrokerEvents(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
@ -18,5 +18,5 @@ export function getBrokerEvents(
|
|||||||
to: query.to,
|
to: query.to,
|
||||||
types: query.types,
|
types: query.types,
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
export { getBrokerEvents, type BrokerEventsQuery } from './api/brokerEventApi';
|
export { type BrokerEventsQuery, getBrokerEvents } from './api/brokerEventApi'
|
||||||
export { useBrokerEvents } from './model/useBrokerEvents';
|
export { useBrokerEvents } from './model/useBrokerEvents'
|
||||||
|
|||||||
@ -1,20 +1,20 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
import { type ReactNode } from 'react';
|
import type { ReactNode } from 'react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { getBrokerEvents } from '../api/brokerEventApi';
|
import { getBrokerEvents } from '../api/brokerEventApi'
|
||||||
import { useBrokerEvents } from './useBrokerEvents';
|
import { useBrokerEvents } from './useBrokerEvents'
|
||||||
|
|
||||||
vi.mock('../api/brokerEventApi', () => ({
|
vi.mock('../api/brokerEventApi', () => ({
|
||||||
getBrokerEvents: vi.fn(),
|
getBrokerEvents: vi.fn(),
|
||||||
}));
|
}))
|
||||||
|
|
||||||
function createWrapper(queryClient?: QueryClient) {
|
function createWrapper(queryClient?: QueryClient) {
|
||||||
const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
|
||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockEventsData = {
|
const mockEventsData = {
|
||||||
@ -53,55 +53,55 @@ const mockEventsData = {
|
|||||||
currency: 'RUB' as const,
|
currency: 'RUB' as const,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
}
|
||||||
|
|
||||||
const query = { from: '2026-06-22', to: '2026-06-29' };
|
const query = { from: '2026-06-22', to: '2026-06-29' }
|
||||||
|
|
||||||
describe('useBrokerEvents', () => {
|
describe('useBrokerEvents', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('returns events data from API', async () => {
|
it('returns events data from API', async () => {
|
||||||
vi.mocked(getBrokerEvents).mockResolvedValue({
|
vi.mocked(getBrokerEvents).mockResolvedValue({
|
||||||
data: mockEventsData,
|
data: mockEventsData,
|
||||||
meta: { fromCache: false, cachedAt: null },
|
meta: { fromCache: false, cachedAt: null },
|
||||||
});
|
})
|
||||||
|
|
||||||
const { result } = renderHook(() => useBrokerEvents('acc-1', query), {
|
const { result } = renderHook(() => useBrokerEvents('acc-1', query), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
})
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
expect(result.current.data?.summary.eventCount).toBe(3);
|
expect(result.current.data?.summary.eventCount).toBe(3)
|
||||||
expect(getBrokerEvents).toHaveBeenCalledWith('acc-1', query);
|
expect(getBrokerEvents).toHaveBeenCalledWith('acc-1', query)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('reuses cache when query key matches', async () => {
|
it('reuses cache when query key matches', async () => {
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
['broker', 'events', 'acc-1', '2026-06-22', '2026-06-29', 'dividend,coupon'],
|
['broker', 'events', 'acc-1', '2026-06-22', '2026-06-29', 'dividend,coupon'],
|
||||||
mockEventsData,
|
mockEventsData,
|
||||||
);
|
)
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useBrokerEvents('acc-1', { ...query, types: 'dividend,coupon' }),
|
() => useBrokerEvents('acc-1', { ...query, types: 'dividend,coupon' }),
|
||||||
{
|
{
|
||||||
wrapper: createWrapper(queryClient),
|
wrapper: createWrapper(queryClient),
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.data).toBe(mockEventsData));
|
await waitFor(() => expect(result.current.data).toBe(mockEventsData))
|
||||||
expect(getBrokerEvents).not.toHaveBeenCalled();
|
expect(getBrokerEvents).not.toHaveBeenCalled()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('is not enabled when accountId is undefined', async () => {
|
it('is not enabled when accountId is undefined', async () => {
|
||||||
const { result } = renderHook(() => useBrokerEvents(undefined, query), {
|
const { result } = renderHook(() => useBrokerEvents(undefined, query), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
})
|
||||||
|
|
||||||
expect(result.current.isPending).toBe(true);
|
expect(result.current.isPending).toBe(true)
|
||||||
expect(getBrokerEvents).not.toHaveBeenCalled();
|
expect(getBrokerEvents).not.toHaveBeenCalled()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import type { BrokerEventsData } from '@/shared/api/responses';
|
import type { BrokerEventsData } from '@/shared/api/responses'
|
||||||
import { getBrokerEvents, type BrokerEventsQuery } from '../api/brokerEventApi';
|
import { type BrokerEventsQuery, getBrokerEvents } from '../api/brokerEventApi'
|
||||||
|
|
||||||
export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) {
|
export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) {
|
||||||
const { from, to, types } = query;
|
const { from, to, types } = query
|
||||||
return useQuery<BrokerEventsData>({
|
return useQuery<BrokerEventsData>({
|
||||||
queryKey: ['broker', 'events', accountId, from, to, types],
|
queryKey: ['broker', 'events', accountId, from, to, types],
|
||||||
enabled: Boolean(accountId),
|
enabled: Boolean(accountId),
|
||||||
@ -11,5 +11,5 @@ export function useBrokerEvents(accountId: string | undefined, query: BrokerEven
|
|||||||
staleTime: 300_000,
|
staleTime: 300_000,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
import { request } from '@/shared/api/client';
|
import { request } from '@/shared/api/kyClient'
|
||||||
import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api/responses';
|
import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api/responses'
|
||||||
|
|
||||||
export type BrokerOperationQuery = {
|
export type BrokerOperationQuery = {
|
||||||
from?: string;
|
from?: string
|
||||||
to?: string;
|
to?: string
|
||||||
cursor?: string;
|
cursor?: string
|
||||||
limit?: number;
|
limit?: number
|
||||||
instrumentId?: string;
|
instrumentId?: string
|
||||||
operationTypes?: string;
|
operationTypes?: string
|
||||||
state?: string;
|
state?: string
|
||||||
};
|
}
|
||||||
|
|
||||||
export function getBrokerOperations(
|
export function getBrokerOperations(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
@ -26,5 +26,5 @@ export function getBrokerOperations(
|
|||||||
operationTypes: query.operationTypes,
|
operationTypes: query.operationTypes,
|
||||||
state: query.state,
|
state: query.state,
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
export { getBrokerOperations, type BrokerOperationQuery } from './api/brokerOperationApi';
|
export { type BrokerOperationQuery, getBrokerOperations } from './api/brokerOperationApi'
|
||||||
export {
|
export {
|
||||||
BROKER_OPERATION_TYPE_OPTIONS,
|
BROKER_OPERATION_TYPE_OPTIONS,
|
||||||
|
type BrokerOperationImpact,
|
||||||
getBrokerOperationImpact,
|
getBrokerOperationImpact,
|
||||||
getBrokerOperationTypeLabel,
|
getBrokerOperationTypeLabel,
|
||||||
isBrokerOperationType,
|
isBrokerOperationType,
|
||||||
type BrokerOperationImpact,
|
} from './model/operationFilters'
|
||||||
} from './model/operationFilters';
|
export { useBrokerOperations } from './model/useBrokerOperations'
|
||||||
export { useBrokerOperations } from './model/useBrokerOperations';
|
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest'
|
||||||
import { BROKER_OPERATION_TYPE_OPTIONS, isBrokerOperationType } from '../model/operationFilters';
|
import { BROKER_OPERATION_TYPE_OPTIONS, isBrokerOperationType } from '../model/operationFilters'
|
||||||
|
|
||||||
describe('operationFilters', () => {
|
describe('operationFilters', () => {
|
||||||
it('accepts only declared broker operation types', () => {
|
it('accepts only declared broker operation types', () => {
|
||||||
expect(isBrokerOperationType('OPERATION_TYPE_BUY')).toBe(true);
|
expect(isBrokerOperationType('OPERATION_TYPE_BUY')).toBe(true)
|
||||||
expect(isBrokerOperationType('unexpected')).toBe(false);
|
expect(isBrokerOperationType('unexpected')).toBe(false)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('keeps operation type option values unique and labels sorted for the filter', () => {
|
it('keeps operation type option values unique and labels sorted for the filter', () => {
|
||||||
const values = BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value);
|
const values = BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value)
|
||||||
const labels = BROKER_OPERATION_TYPE_OPTIONS.map(({ label }) => label);
|
const labels = BROKER_OPERATION_TYPE_OPTIONS.map(({ label }) => label)
|
||||||
|
|
||||||
expect(new Set(values).size).toBe(values.length);
|
expect(new Set(values).size).toBe(values.length)
|
||||||
expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru')));
|
expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru')))
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { BrokerOperation } from '@/shared/api/responses';
|
import type { BrokerOperation } from '@/shared/api/responses'
|
||||||
|
|
||||||
export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown';
|
export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown'
|
||||||
|
|
||||||
const TRADE_TYPES = new Set([
|
const TRADE_TYPES = new Set([
|
||||||
'OPERATION_TYPE_BUY',
|
'OPERATION_TYPE_BUY',
|
||||||
@ -11,14 +11,14 @@ const TRADE_TYPES = new Set([
|
|||||||
'OPERATION_TYPE_SELL_MARGIN',
|
'OPERATION_TYPE_SELL_MARGIN',
|
||||||
'OPERATION_TYPE_DELIVERY_BUY',
|
'OPERATION_TYPE_DELIVERY_BUY',
|
||||||
'OPERATION_TYPE_DELIVERY_SELL',
|
'OPERATION_TYPE_DELIVERY_SELL',
|
||||||
]);
|
])
|
||||||
|
|
||||||
const BOND_REPAYMENT_TYPES = new Set([
|
const BOND_REPAYMENT_TYPES = new Set([
|
||||||
'OPERATION_TYPE_BOND_REPAYMENT',
|
'OPERATION_TYPE_BOND_REPAYMENT',
|
||||||
'OPERATION_TYPE_BOND_REPAYMENT_FULL',
|
'OPERATION_TYPE_BOND_REPAYMENT_FULL',
|
||||||
]);
|
])
|
||||||
|
|
||||||
const INCOME_TYPES = new Set(['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND']);
|
const INCOME_TYPES = new Set(['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND'])
|
||||||
|
|
||||||
const TAX_TYPES = new Set([
|
const TAX_TYPES = new Set([
|
||||||
'OPERATION_TYPE_TAX',
|
'OPERATION_TYPE_TAX',
|
||||||
@ -26,35 +26,35 @@ const TAX_TYPES = new Set([
|
|||||||
'OPERATION_TYPE_DIVIDEND_TAX',
|
'OPERATION_TYPE_DIVIDEND_TAX',
|
||||||
'OPERATION_TYPE_TAX_CORRECTION',
|
'OPERATION_TYPE_TAX_CORRECTION',
|
||||||
'OPERATION_TYPE_TAX_CORRECTION_COUPON',
|
'OPERATION_TYPE_TAX_CORRECTION_COUPON',
|
||||||
]);
|
])
|
||||||
|
|
||||||
const FEE_TYPES = new Set([
|
const FEE_TYPES = new Set([
|
||||||
'OPERATION_TYPE_BROKER_FEE',
|
'OPERATION_TYPE_BROKER_FEE',
|
||||||
'OPERATION_TYPE_SERVICE_FEE',
|
'OPERATION_TYPE_SERVICE_FEE',
|
||||||
'OPERATION_TYPE_MARGIN_FEE',
|
'OPERATION_TYPE_MARGIN_FEE',
|
||||||
'OPERATION_TYPE_SUCCESS_FEE',
|
'OPERATION_TYPE_SUCCESS_FEE',
|
||||||
]);
|
])
|
||||||
|
|
||||||
const TRANSFER_INPUT_TYPES = new Set([
|
const TRANSFER_INPUT_TYPES = new Set([
|
||||||
'OPERATION_TYPE_INPUT',
|
'OPERATION_TYPE_INPUT',
|
||||||
'OPERATION_TYPE_INPUT_SWIFT',
|
'OPERATION_TYPE_INPUT_SWIFT',
|
||||||
'OPERATION_TYPE_INPUT_ACQUIRING',
|
'OPERATION_TYPE_INPUT_ACQUIRING',
|
||||||
'OPERATION_TYPE_INP_MULTI',
|
'OPERATION_TYPE_INP_MULTI',
|
||||||
]);
|
])
|
||||||
|
|
||||||
const TRANSFER_OUTPUT_TYPES = new Set([
|
const TRANSFER_OUTPUT_TYPES = new Set([
|
||||||
'OPERATION_TYPE_OUTPUT',
|
'OPERATION_TYPE_OUTPUT',
|
||||||
'OPERATION_TYPE_OUTPUT_SWIFT',
|
'OPERATION_TYPE_OUTPUT_SWIFT',
|
||||||
'OPERATION_TYPE_OUTPUT_ACQUIRING',
|
'OPERATION_TYPE_OUTPUT_ACQUIRING',
|
||||||
'OPERATION_TYPE_OUT_MULTI',
|
'OPERATION_TYPE_OUT_MULTI',
|
||||||
]);
|
])
|
||||||
|
|
||||||
const SECURITY_TRANSFER_TYPES = new Set([
|
const SECURITY_TRANSFER_TYPES = new Set([
|
||||||
'OPERATION_TYPE_INPUT_SECURITIES',
|
'OPERATION_TYPE_INPUT_SECURITIES',
|
||||||
'OPERATION_TYPE_OUTPUT_SECURITIES',
|
'OPERATION_TYPE_OUTPUT_SECURITIES',
|
||||||
'OPERATION_TYPE_TRANS_IIS_BS',
|
'OPERATION_TYPE_TRANS_IIS_BS',
|
||||||
'OPERATION_TYPE_TRANS_BS_BS',
|
'OPERATION_TYPE_TRANS_BS_BS',
|
||||||
]);
|
])
|
||||||
|
|
||||||
const OPERATION_TYPE_LABELS: Record<string, string> = {
|
const OPERATION_TYPE_LABELS: Record<string, string> = {
|
||||||
OPERATION_TYPE_BUY: 'Покупка',
|
OPERATION_TYPE_BUY: 'Покупка',
|
||||||
@ -82,7 +82,7 @@ const OPERATION_TYPE_LABELS: Record<string, string> = {
|
|||||||
OPERATION_TYPE_OUTPUT: 'Вывод средств',
|
OPERATION_TYPE_OUTPUT: 'Вывод средств',
|
||||||
OPERATION_TYPE_INPUT_SECURITIES: 'Зачисление бумаг',
|
OPERATION_TYPE_INPUT_SECURITIES: 'Зачисление бумаг',
|
||||||
OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг',
|
OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг',
|
||||||
};
|
}
|
||||||
|
|
||||||
export const BROKER_OPERATION_TYPE_OPTIONS: ReadonlyArray<
|
export const BROKER_OPERATION_TYPE_OPTIONS: ReadonlyArray<
|
||||||
Readonly<{ value: string; label: string }>
|
Readonly<{ value: string; label: string }>
|
||||||
@ -90,25 +90,25 @@ export const BROKER_OPERATION_TYPE_OPTIONS: ReadonlyArray<
|
|||||||
Object.entries(OPERATION_TYPE_LABELS)
|
Object.entries(OPERATION_TYPE_LABELS)
|
||||||
.map(([value, label]) => Object.freeze({ value, label }))
|
.map(([value, label]) => Object.freeze({ value, label }))
|
||||||
.sort((left, right) => left.label.localeCompare(right.label, 'ru')),
|
.sort((left, right) => left.label.localeCompare(right.label, 'ru')),
|
||||||
);
|
)
|
||||||
|
|
||||||
const BROKER_OPERATION_TYPES = new Set(BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value));
|
const BROKER_OPERATION_TYPES = new Set(BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value))
|
||||||
|
|
||||||
export function isBrokerOperationType(value: string | null): value is string {
|
export function isBrokerOperationType(value: string | null): value is string {
|
||||||
return value !== null && BROKER_OPERATION_TYPES.has(value);
|
return value !== null && BROKER_OPERATION_TYPES.has(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBrokerOperationTypeLabel(
|
export function getBrokerOperationTypeLabel(
|
||||||
operation: Pick<BrokerOperation, 'type' | 'description'>,
|
operation: Pick<BrokerOperation, 'type' | 'description'>,
|
||||||
): string {
|
): string {
|
||||||
const knownLabel = OPERATION_TYPE_LABELS[operation.type];
|
const knownLabel = OPERATION_TYPE_LABELS[operation.type]
|
||||||
if (knownLabel) return knownLabel;
|
if (knownLabel) return knownLabel
|
||||||
if (operation.description) return operation.description;
|
if (operation.description) return operation.description
|
||||||
|
|
||||||
return operation.type
|
return operation.type
|
||||||
.replace(/^OPERATION_TYPE_/, '')
|
.replace(/^OPERATION_TYPE_/, '')
|
||||||
.replace(/_/g, ' ')
|
.replace(/_/g, ' ')
|
||||||
.toLowerCase();
|
.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBrokerOperationImpact(
|
export function getBrokerOperationImpact(
|
||||||
@ -119,15 +119,15 @@ export function getBrokerOperationImpact(
|
|||||||
BOND_REPAYMENT_TYPES.has(operation.type) ||
|
BOND_REPAYMENT_TYPES.has(operation.type) ||
|
||||||
SECURITY_TRANSFER_TYPES.has(operation.type)
|
SECURITY_TRANSFER_TYPES.has(operation.type)
|
||||||
) {
|
) {
|
||||||
return 'neutral';
|
return 'neutral'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (INCOME_TYPES.has(operation.type)) return 'adds';
|
if (INCOME_TYPES.has(operation.type)) return 'adds'
|
||||||
if (TAX_TYPES.has(operation.type) || FEE_TYPES.has(operation.type)) return 'reduces';
|
if (TAX_TYPES.has(operation.type) || FEE_TYPES.has(operation.type)) return 'reduces'
|
||||||
if (TRANSFER_INPUT_TYPES.has(operation.type)) return 'adds';
|
if (TRANSFER_INPUT_TYPES.has(operation.type)) return 'adds'
|
||||||
if (TRANSFER_OUTPUT_TYPES.has(operation.type)) return 'reduces';
|
if (TRANSFER_OUTPUT_TYPES.has(operation.type)) return 'reduces'
|
||||||
if (operation.category === 'tax' || operation.category === 'fee') return 'reduces';
|
if (operation.category === 'tax' || operation.category === 'fee') return 'reduces'
|
||||||
if (operation.category === 'income' && (operation.payment?.value ?? 0) > 0) return 'adds';
|
if (operation.category === 'income' && (operation.payment?.value ?? 0) > 0) return 'adds'
|
||||||
|
|
||||||
return 'unknown';
|
return 'unknown'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,26 +1,26 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
import { type ReactNode } from 'react';
|
import type { ReactNode } from 'react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { getBrokerOperations } from '../api/brokerOperationApi';
|
import { getBrokerOperations } from '../api/brokerOperationApi'
|
||||||
import { useBrokerOperations } from '../model/useBrokerOperations';
|
import { useBrokerOperations } from '../model/useBrokerOperations'
|
||||||
|
|
||||||
vi.mock('../api/brokerOperationApi', () => ({
|
vi.mock('../api/brokerOperationApi', () => ({
|
||||||
getBrokerOperations: vi.fn(),
|
getBrokerOperations: vi.fn(),
|
||||||
}));
|
}))
|
||||||
|
|
||||||
function createWrapper(queryClient?: QueryClient) {
|
function createWrapper(queryClient?: QueryClient) {
|
||||||
const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
|
||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('useBrokerOperations', () => {
|
describe('useBrokerOperations', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('returns operations page data from API', async () => {
|
it('returns operations page data from API', async () => {
|
||||||
vi.mocked(getBrokerOperations).mockResolvedValue({
|
vi.mocked(getBrokerOperations).mockResolvedValue({
|
||||||
@ -32,34 +32,34 @@ describe('useBrokerOperations', () => {
|
|||||||
asOf: '2026-06-19T00:00:00.000Z',
|
asOf: '2026-06-19T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
meta: { fromCache: false, cachedAt: null },
|
meta: { fromCache: false, cachedAt: null },
|
||||||
});
|
})
|
||||||
|
|
||||||
const { result } = renderHook(() => useBrokerOperations('acc-1', { limit: 5 }), {
|
const { result } = renderHook(() => useBrokerOperations('acc-1', { limit: 5 }), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
})
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
expect(result.current.data?.accountId).toBe('acc-1');
|
expect(result.current.data?.accountId).toBe('acc-1')
|
||||||
expect(getBrokerOperations).toHaveBeenCalledWith('acc-1', { limit: 5 });
|
expect(getBrokerOperations).toHaveBeenCalledWith('acc-1', { limit: 5 })
|
||||||
});
|
})
|
||||||
|
|
||||||
it('reuses the broker operations cache key across the account overview and full history pages', async () => {
|
it('reuses the broker operations cache key across the account overview and full history pages', async () => {
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
const cachedPage = {
|
const cachedPage = {
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
items: [],
|
items: [],
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
hasNext: false,
|
hasNext: false,
|
||||||
asOf: '2026-06-19T00:00:00.000Z',
|
asOf: '2026-06-19T00:00:00.000Z',
|
||||||
};
|
}
|
||||||
|
|
||||||
queryClient.setQueryData(['broker', 'operations', 'acc-1', { limit: 5 }], cachedPage);
|
queryClient.setQueryData(['broker', 'operations', 'acc-1', { limit: 5 }], cachedPage)
|
||||||
|
|
||||||
const { result } = renderHook(() => useBrokerOperations('acc-1', { limit: 5 }), {
|
const { result } = renderHook(() => useBrokerOperations('acc-1', { limit: 5 }), {
|
||||||
wrapper: createWrapper(queryClient),
|
wrapper: createWrapper(queryClient),
|
||||||
});
|
})
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.data).toBe(cachedPage));
|
await waitFor(() => expect(result.current.data).toBe(cachedPage))
|
||||||
expect(getBrokerOperations).not.toHaveBeenCalled();
|
expect(getBrokerOperations).not.toHaveBeenCalled()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||||
import type { BrokerOperationsPage } from '@/shared/api/responses';
|
import type { BrokerOperationsPage } from '@/shared/api/responses'
|
||||||
import { getBrokerOperations, type BrokerOperationQuery } from '../api/brokerOperationApi';
|
import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOperationApi'
|
||||||
|
|
||||||
export function useBrokerOperations(
|
export function useBrokerOperations(
|
||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
@ -14,5 +14,5 @@ export function useBrokerOperations(
|
|||||||
retry: 2,
|
retry: 2,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { request } from '@/shared/api/client';
|
import { request } from '@/shared/api/kyClient'
|
||||||
import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api/responses';
|
import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api/responses'
|
||||||
|
|
||||||
export function getBrokerPositions(
|
export function getBrokerPositions(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
@ -12,5 +12,5 @@ export function getBrokerPositions(
|
|||||||
limit: query.limit ? String(query.limit) : undefined,
|
limit: query.limit ? String(query.limit) : undefined,
|
||||||
type: query.type,
|
type: query.type,
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
export { getBrokerPositions } from './api/brokerPositionApi';
|
export { getBrokerPositions } from './api/brokerPositionApi'
|
||||||
export {
|
export {
|
||||||
buildBrokerAllocation,
|
|
||||||
type BrokerAllocationItem,
|
type BrokerAllocationItem,
|
||||||
type BrokerAllocationKey,
|
type BrokerAllocationKey,
|
||||||
} from './model/brokerAllocation';
|
buildBrokerAllocation,
|
||||||
|
} from './model/brokerAllocation'
|
||||||
export {
|
export {
|
||||||
|
type BrokerPositionGroup,
|
||||||
getBrokerInstrumentPath,
|
getBrokerInstrumentPath,
|
||||||
getBrokerPositionGroup,
|
getBrokerPositionGroup,
|
||||||
type BrokerPositionGroup,
|
} from './model/brokerDisplay'
|
||||||
} from './model/brokerDisplay';
|
export { useBrokerPositions } from './model/useBrokerPositions'
|
||||||
export { useBrokerPositions } from './model/useBrokerPositions';
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
|
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'
|
||||||
import { buildBrokerAllocation } from './brokerAllocation';
|
import { buildBrokerAllocation } from './brokerAllocation'
|
||||||
|
|
||||||
function money(value: number): BrokerMoney {
|
function money(value: number): BrokerMoney {
|
||||||
return {
|
return {
|
||||||
@ -8,16 +8,16 @@ function money(value: number): BrokerMoney {
|
|||||||
units: String(Math.trunc(value)),
|
units: String(Math.trunc(value)),
|
||||||
nano: 0,
|
nano: 0,
|
||||||
value,
|
value,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function portfolio(
|
function portfolio(
|
||||||
values: Partial<Record<'shares' | 'bonds' | 'etf' | 'currencies' | 'portfolio', number | null>>,
|
values: Partial<Record<'shares' | 'bonds' | 'etf' | 'currencies' | 'portfolio', number | null>>,
|
||||||
): BrokerPortfolio {
|
): BrokerPortfolio {
|
||||||
const total = (key: keyof typeof values): BrokerMoney | null => {
|
const total = (key: keyof typeof values): BrokerMoney | null => {
|
||||||
const value = values[key];
|
const value = values[key]
|
||||||
return value == null ? null : money(value);
|
return value == null ? null : money(value)
|
||||||
};
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
account: {
|
account: {
|
||||||
@ -53,7 +53,7 @@ function portfolio(
|
|||||||
cash: [],
|
cash: [],
|
||||||
blockedCash: [],
|
blockedCash: [],
|
||||||
asOf: '2025-01-01T00:00:00.000Z',
|
asOf: '2025-01-01T00:00:00.000Z',
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('buildBrokerAllocation', () => {
|
describe('buildBrokerAllocation', () => {
|
||||||
@ -72,73 +72,71 @@ describe('buildBrokerAllocation', () => {
|
|||||||
{ key: 'other', label: 'Прочие', value: 50, percent: 5, color: '#aeb6c5' },
|
{ key: 'other', label: 'Прочие', value: 50, percent: 5, color: '#aeb6c5' },
|
||||||
],
|
],
|
||||||
negative: [],
|
negative: [],
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
it('omits zero-value sectors', () => {
|
it('omits zero-value sectors', () => {
|
||||||
const result = buildBrokerAllocation(
|
const result = buildBrokerAllocation(
|
||||||
portfolio({ shares: 600, bonds: 0, etf: null, currencies: 400, portfolio: 1000 }),
|
portfolio({ shares: 600, bonds: 0, etf: null, currencies: 400, portfolio: 1000 }),
|
||||||
);
|
)
|
||||||
|
|
||||||
expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'cash']);
|
expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'cash'])
|
||||||
expect(result.negative).toEqual([]);
|
expect(result.negative).toEqual([])
|
||||||
});
|
})
|
||||||
|
|
||||||
it('reports a negative residual outside the sectors', () => {
|
it('reports a negative residual outside the sectors', () => {
|
||||||
const result = buildBrokerAllocation(
|
const result = buildBrokerAllocation(
|
||||||
portfolio({ shares: 700, bonds: 300, etf: 100, currencies: 50, portfolio: 1000 }),
|
portfolio({ shares: 700, bonds: 300, etf: 100, currencies: 50, portfolio: 1000 }),
|
||||||
);
|
)
|
||||||
|
|
||||||
expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds', 'etf', 'cash']);
|
expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds', 'etf', 'cash'])
|
||||||
expect(result.negative).toEqual([
|
expect(result.negative).toEqual([
|
||||||
{ key: 'other', label: 'Прочие', value: -150, color: '#aeb6c5' },
|
{ key: 'other', label: 'Прочие', value: -150, color: '#aeb6c5' },
|
||||||
]);
|
])
|
||||||
});
|
})
|
||||||
|
|
||||||
it('ignores a tiny negative residual caused by decimal arithmetic', () => {
|
it('ignores a tiny negative residual caused by decimal arithmetic', () => {
|
||||||
const result = buildBrokerAllocation(portfolio({ shares: 0.1, bonds: 0.2, portfolio: 0.3 }));
|
const result = buildBrokerAllocation(portfolio({ shares: 0.1, bonds: 0.2, portfolio: 0.3 }))
|
||||||
|
|
||||||
expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds']);
|
expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds'])
|
||||||
expect(result.negative).toEqual([]);
|
expect(result.negative).toEqual([])
|
||||||
});
|
})
|
||||||
|
|
||||||
it('ignores a tiny positive residual caused by decimal arithmetic', () => {
|
it('ignores a tiny positive residual caused by decimal arithmetic', () => {
|
||||||
const result = buildBrokerAllocation(
|
const result = buildBrokerAllocation(portfolio({ shares: 0.3, portfolio: 0.30000000000000004 }))
|
||||||
portfolio({ shares: 0.3, portfolio: 0.30000000000000004 }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.sectors.map(({ key }) => key)).toEqual(['shares']);
|
expect(result.sectors.map(({ key }) => key)).toEqual(['shares'])
|
||||||
expect(result.negative).toEqual([]);
|
expect(result.negative).toEqual([])
|
||||||
});
|
})
|
||||||
|
|
||||||
it('returns no allocation for missing or nonpositive portfolio totals', () => {
|
it('returns no allocation for missing or nonpositive portfolio totals', () => {
|
||||||
expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: null }))).toEqual({
|
expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: null }))).toEqual({
|
||||||
total: 0,
|
total: 0,
|
||||||
sectors: [],
|
sectors: [],
|
||||||
negative: [],
|
negative: [],
|
||||||
});
|
})
|
||||||
expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: 0 }))).toEqual({
|
expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: 0 }))).toEqual({
|
||||||
total: 0,
|
total: 0,
|
||||||
sectors: [],
|
sectors: [],
|
||||||
negative: [],
|
negative: [],
|
||||||
});
|
})
|
||||||
expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: -10 }))).toEqual({
|
expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: -10 }))).toEqual({
|
||||||
total: -10,
|
total: -10,
|
||||||
sectors: [],
|
sectors: [],
|
||||||
negative: [],
|
negative: [],
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
it('preserves named negative components when the portfolio total is nonpositive', () => {
|
it('preserves named negative components when the portfolio total is nonpositive', () => {
|
||||||
expect(buildBrokerAllocation(portfolio({ shares: 100, bonds: -20, portfolio: 0 }))).toEqual({
|
expect(buildBrokerAllocation(portfolio({ shares: 100, bonds: -20, portfolio: 0 }))).toEqual({
|
||||||
total: 0,
|
total: 0,
|
||||||
sectors: [],
|
sectors: [],
|
||||||
negative: [{ key: 'bonds', label: 'Облигации', value: -20, color: '#e5a33c' }],
|
negative: [{ key: 'bonds', label: 'Облигации', value: -20, color: '#e5a33c' }],
|
||||||
});
|
})
|
||||||
expect(buildBrokerAllocation(portfolio({ currencies: -30, etf: 5, portfolio: -10 }))).toEqual({
|
expect(buildBrokerAllocation(portfolio({ currencies: -30, etf: 5, portfolio: -10 }))).toEqual({
|
||||||
total: -10,
|
total: -10,
|
||||||
sectors: [],
|
sectors: [],
|
||||||
negative: [{ key: 'cash', label: 'Деньги', value: -30, color: '#7b63cf' }],
|
negative: [{ key: 'cash', label: 'Деньги', value: -30, color: '#7b63cf' }],
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
import type { BrokerPortfolio } from '@/shared/api/responses';
|
import type { BrokerPortfolio } from '@/shared/api/responses'
|
||||||
|
|
||||||
export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other';
|
export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other'
|
||||||
|
|
||||||
export interface BrokerAllocationItem {
|
export interface BrokerAllocationItem {
|
||||||
key: BrokerAllocationKey;
|
key: BrokerAllocationKey
|
||||||
label: string;
|
label: string
|
||||||
value: number;
|
value: number
|
||||||
percent: number;
|
percent: number
|
||||||
color: string;
|
color: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type BrokerNegativeAllocationItem = Omit<BrokerAllocationItem, 'percent'>;
|
type BrokerNegativeAllocationItem = Omit<BrokerAllocationItem, 'percent'>
|
||||||
|
|
||||||
const ALLOCATION_CONFIG: Array<Pick<BrokerAllocationItem, 'key' | 'label' | 'color'>> = [
|
const ALLOCATION_CONFIG: Array<Pick<BrokerAllocationItem, 'key' | 'label' | 'color'>> = [
|
||||||
{ key: 'shares', label: 'Акции', color: '#4969f5' },
|
{ key: 'shares', label: 'Акции', color: '#4969f5' },
|
||||||
@ -18,40 +18,40 @@ const ALLOCATION_CONFIG: Array<Pick<BrokerAllocationItem, 'key' | 'label' | 'col
|
|||||||
{ key: 'etf', label: 'ETF/фонды', color: '#62b889' },
|
{ key: 'etf', label: 'ETF/фонды', color: '#62b889' },
|
||||||
{ key: 'cash', label: 'Деньги', color: '#7b63cf' },
|
{ key: 'cash', label: 'Деньги', color: '#7b63cf' },
|
||||||
{ key: 'other', label: 'Прочие', color: '#aeb6c5' },
|
{ key: 'other', label: 'Прочие', color: '#aeb6c5' },
|
||||||
];
|
]
|
||||||
|
|
||||||
export function buildBrokerAllocation(portfolio: BrokerPortfolio): {
|
export function buildBrokerAllocation(portfolio: BrokerPortfolio): {
|
||||||
total: number;
|
total: number
|
||||||
sectors: BrokerAllocationItem[];
|
sectors: BrokerAllocationItem[]
|
||||||
negative: BrokerNegativeAllocationItem[];
|
negative: BrokerNegativeAllocationItem[]
|
||||||
} {
|
} {
|
||||||
const total = portfolio.totals.portfolio?.value ?? 0;
|
const total = portfolio.totals.portfolio?.value ?? 0
|
||||||
const shares = portfolio.totals.shares?.value ?? 0;
|
const shares = portfolio.totals.shares?.value ?? 0
|
||||||
const bonds = portfolio.totals.bonds?.value ?? 0;
|
const bonds = portfolio.totals.bonds?.value ?? 0
|
||||||
const etf = portfolio.totals.etf?.value ?? 0;
|
const etf = portfolio.totals.etf?.value ?? 0
|
||||||
const cash = portfolio.totals.currencies?.value ?? 0;
|
const cash = portfolio.totals.currencies?.value ?? 0
|
||||||
const namedValues: Record<Exclude<BrokerAllocationKey, 'other'>, number> = {
|
const namedValues: Record<Exclude<BrokerAllocationKey, 'other'>, number> = {
|
||||||
shares,
|
shares,
|
||||||
bonds,
|
bonds,
|
||||||
etf,
|
etf,
|
||||||
cash,
|
cash,
|
||||||
};
|
}
|
||||||
|
|
||||||
if (total <= 0) {
|
if (total <= 0) {
|
||||||
const negative = ALLOCATION_CONFIG.filter(
|
const negative = ALLOCATION_CONFIG.filter(
|
||||||
(
|
(
|
||||||
item,
|
item,
|
||||||
): item is (typeof ALLOCATION_CONFIG)[number] & {
|
): item is (typeof ALLOCATION_CONFIG)[number] & {
|
||||||
key: Exclude<BrokerAllocationKey, 'other'>;
|
key: Exclude<BrokerAllocationKey, 'other'>
|
||||||
} => item.key !== 'other',
|
} => item.key !== 'other',
|
||||||
)
|
)
|
||||||
.filter((item) => namedValues[item.key] < 0)
|
.filter((item) => namedValues[item.key] < 0)
|
||||||
.map((item) => ({ ...item, value: namedValues[item.key] }));
|
.map((item) => ({ ...item, value: namedValues[item.key] }))
|
||||||
return { total, sectors: [], negative };
|
return { total, sectors: [], negative }
|
||||||
}
|
}
|
||||||
|
|
||||||
const mappedTotal = shares + bonds + etf + cash;
|
const mappedTotal = shares + bonds + etf + cash
|
||||||
const residual = total - mappedTotal;
|
const residual = total - mappedTotal
|
||||||
const residualTolerance =
|
const residualTolerance =
|
||||||
Number.EPSILON *
|
Number.EPSILON *
|
||||||
Math.max(
|
Math.max(
|
||||||
@ -59,27 +59,27 @@ export function buildBrokerAllocation(portfolio: BrokerPortfolio): {
|
|||||||
Math.abs(total),
|
Math.abs(total),
|
||||||
Math.abs(shares) + Math.abs(bonds) + Math.abs(etf) + Math.abs(cash),
|
Math.abs(shares) + Math.abs(bonds) + Math.abs(etf) + Math.abs(cash),
|
||||||
) *
|
) *
|
||||||
8;
|
8
|
||||||
const values: Record<BrokerAllocationKey, number> = {
|
const values: Record<BrokerAllocationKey, number> = {
|
||||||
shares,
|
shares,
|
||||||
bonds,
|
bonds,
|
||||||
etf,
|
etf,
|
||||||
cash,
|
cash,
|
||||||
other: Math.abs(residual) <= residualTolerance ? 0 : residual,
|
other: Math.abs(residual) <= residualTolerance ? 0 : residual,
|
||||||
};
|
}
|
||||||
|
|
||||||
const sectors: BrokerAllocationItem[] = [];
|
const sectors: BrokerAllocationItem[] = []
|
||||||
const negative: BrokerNegativeAllocationItem[] = [];
|
const negative: BrokerNegativeAllocationItem[] = []
|
||||||
|
|
||||||
for (const item of ALLOCATION_CONFIG) {
|
for (const item of ALLOCATION_CONFIG) {
|
||||||
const value = values[item.key];
|
const value = values[item.key]
|
||||||
|
|
||||||
if (value > 0) {
|
if (value > 0) {
|
||||||
sectors.push({ ...item, value, percent: (value / total) * 100 });
|
sectors.push({ ...item, value, percent: (value / total) * 100 })
|
||||||
} else if (value < 0) {
|
} else if (value < 0) {
|
||||||
negative.push({ ...item, value });
|
negative.push({ ...item, value })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { total, sectors, negative };
|
return { total, sectors, negative }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { BrokerOperation, BrokerPosition } from '@/shared/api/responses';
|
|
||||||
import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay';
|
|
||||||
import {
|
import {
|
||||||
BROKER_OPERATION_TYPE_OPTIONS,
|
BROKER_OPERATION_TYPE_OPTIONS,
|
||||||
getBrokerOperationImpact,
|
getBrokerOperationImpact,
|
||||||
getBrokerOperationTypeLabel,
|
getBrokerOperationTypeLabel,
|
||||||
isBrokerOperationType,
|
isBrokerOperationType,
|
||||||
} from '@/entities/broker-operation';
|
} from '@/entities/broker-operation'
|
||||||
|
import type { BrokerOperation, BrokerPosition } from '@/shared/api/responses'
|
||||||
|
import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay'
|
||||||
|
|
||||||
function position(input: Partial<BrokerPosition>): BrokerPosition {
|
function position(input: Partial<BrokerPosition>): BrokerPosition {
|
||||||
return {
|
return {
|
||||||
@ -25,7 +25,7 @@ function position(input: Partial<BrokerPosition>): BrokerPosition {
|
|||||||
expectedYieldPercent: null,
|
expectedYieldPercent: null,
|
||||||
dailyYield: null,
|
dailyYield: null,
|
||||||
...input,
|
...input,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function operation(input: Partial<BrokerOperation>): BrokerOperation {
|
function operation(input: Partial<BrokerOperation>): BrokerOperation {
|
||||||
@ -53,70 +53,70 @@ function operation(input: Partial<BrokerOperation>): BrokerOperation {
|
|||||||
quantity: null,
|
quantity: null,
|
||||||
quantityDone: null,
|
quantityDone: null,
|
||||||
...input,
|
...input,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('broker display helpers', () => {
|
describe('broker display helpers', () => {
|
||||||
it('groups positions by instrument type', () => {
|
it('groups positions by instrument type', () => {
|
||||||
expect(getBrokerPositionGroup(position({ instrumentType: 'share' }))).toBe('shares');
|
expect(getBrokerPositionGroup(position({ instrumentType: 'share' }))).toBe('shares')
|
||||||
expect(getBrokerPositionGroup(position({ instrumentType: 'bond' }))).toBe('bonds');
|
expect(getBrokerPositionGroup(position({ instrumentType: 'bond' }))).toBe('bonds')
|
||||||
expect(getBrokerPositionGroup(position({ instrumentType: 'etf' }))).toBe('other');
|
expect(getBrokerPositionGroup(position({ instrumentType: 'etf' }))).toBe('other')
|
||||||
expect(getBrokerPositionGroup(position({ instrumentType: null }))).toBe('other');
|
expect(getBrokerPositionGroup(position({ instrumentType: null }))).toBe('other')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('builds stock and bond routes from instrument metadata', () => {
|
it('builds stock and bond routes from instrument metadata', () => {
|
||||||
expect(
|
expect(
|
||||||
getBrokerInstrumentPath({ ticker: 'sber', instrumentType: 'share', classCode: 'TQBR' }),
|
getBrokerInstrumentPath({ ticker: 'sber', instrumentType: 'share', classCode: 'TQBR' }),
|
||||||
).toBe('/stocks/SBER');
|
).toBe('/stocks/SBER')
|
||||||
expect(
|
expect(
|
||||||
getBrokerInstrumentPath({
|
getBrokerInstrumentPath({
|
||||||
ticker: 'SU26238RMFS5',
|
ticker: 'SU26238RMFS5',
|
||||||
instrumentType: 'bond',
|
instrumentType: 'bond',
|
||||||
classCode: 'TQOB',
|
classCode: 'TQOB',
|
||||||
}),
|
}),
|
||||||
).toBe('/bonds/SU26238RMFS5');
|
).toBe('/bonds/SU26238RMFS5')
|
||||||
expect(
|
expect(
|
||||||
getBrokerInstrumentPath({ ticker: null, instrumentType: 'share', classCode: 'TQBR' }),
|
getBrokerInstrumentPath({ ticker: null, instrumentType: 'share', classCode: 'TQBR' }),
|
||||||
).toBeNull();
|
).toBeNull()
|
||||||
expect(
|
expect(
|
||||||
getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQTF' }),
|
getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQTF' }),
|
||||||
).toBeNull();
|
).toBeNull()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('uses class code fallback when instrument type is missing', () => {
|
it('uses class code fallback when instrument type is missing', () => {
|
||||||
expect(
|
expect(
|
||||||
getBrokerInstrumentPath({ ticker: 'SBER', instrumentType: null, classCode: 'TQBR' }),
|
getBrokerInstrumentPath({ ticker: 'SBER', instrumentType: null, classCode: 'TQBR' }),
|
||||||
).toBe('/stocks/SBER');
|
).toBe('/stocks/SBER')
|
||||||
expect(
|
expect(
|
||||||
getBrokerInstrumentPath({ ticker: 'RU000A0JX0J2', instrumentType: null, classCode: 'TQOB' }),
|
getBrokerInstrumentPath({ ticker: 'RU000A0JX0J2', instrumentType: null, classCode: 'TQOB' }),
|
||||||
).toBe('/bonds/RU000A0JX0J2');
|
).toBe('/bonds/RU000A0JX0J2')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('does not let class code override a known unsupported or conflicting instrument type', () => {
|
it('does not let class code override a known unsupported or conflicting instrument type', () => {
|
||||||
expect(
|
expect(
|
||||||
getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQBR' }),
|
getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQBR' }),
|
||||||
).toBeNull();
|
).toBeNull()
|
||||||
expect(
|
expect(
|
||||||
getBrokerInstrumentPath({
|
getBrokerInstrumentPath({
|
||||||
ticker: 'SU26238RMFS5',
|
ticker: 'SU26238RMFS5',
|
||||||
instrumentType: 'bond',
|
instrumentType: 'bond',
|
||||||
classCode: 'TQBR',
|
classCode: 'TQBR',
|
||||||
}),
|
}),
|
||||||
).toBe('/bonds/SU26238RMFS5');
|
).toBe('/bonds/SU26238RMFS5')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('maps operation enum values to Russian labels', () => {
|
it('maps operation enum values to Russian labels', () => {
|
||||||
expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_COUPON' }))).toBe(
|
expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_COUPON' }))).toBe(
|
||||||
'Выплата купона',
|
'Выплата купона',
|
||||||
);
|
)
|
||||||
expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_TAX' }))).toBe('Налог');
|
expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_TAX' }))).toBe('Налог')
|
||||||
expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_BUY' }))).toBe('Покупка');
|
expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_BUY' }))).toBe('Покупка')
|
||||||
expect(
|
expect(
|
||||||
getBrokerOperationTypeLabel(
|
getBrokerOperationTypeLabel(
|
||||||
operation({ type: 'OPERATION_TYPE_UNKNOWN_VALUE', description: 'Custom' }),
|
operation({ type: 'OPERATION_TYPE_UNKNOWN_VALUE', description: 'Custom' }),
|
||||||
),
|
),
|
||||||
).toBe('Custom');
|
).toBe('Custom')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('exposes independently selectable known operation types', () => {
|
it('exposes independently selectable known operation types', () => {
|
||||||
expect(BROKER_OPERATION_TYPE_OPTIONS).toEqual(
|
expect(BROKER_OPERATION_TYPE_OPTIONS).toEqual(
|
||||||
@ -126,28 +126,28 @@ describe('broker display helpers', () => {
|
|||||||
{ value: 'OPERATION_TYPE_BOND_TAX', label: 'Налог по облигациям' },
|
{ value: 'OPERATION_TYPE_BOND_TAX', label: 'Налог по облигациям' },
|
||||||
{ value: 'OPERATION_TYPE_DIVIDEND_TAX', label: 'Налог на дивиденды' },
|
{ value: 'OPERATION_TYPE_DIVIDEND_TAX', label: 'Налог на дивиденды' },
|
||||||
]),
|
]),
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('keeps operation type option values unique and labels in Russian order', () => {
|
it('keeps operation type option values unique and labels in Russian order', () => {
|
||||||
const values = BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value);
|
const values = BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value)
|
||||||
const labels = BROKER_OPERATION_TYPE_OPTIONS.map(({ label }) => label);
|
const labels = BROKER_OPERATION_TYPE_OPTIONS.map(({ label }) => label)
|
||||||
|
|
||||||
expect(new Set(values).size).toBe(values.length);
|
expect(new Set(values).size).toBe(values.length)
|
||||||
expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru')));
|
expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru')))
|
||||||
});
|
})
|
||||||
|
|
||||||
it('keeps operation type options immutable at runtime', () => {
|
it('keeps operation type options immutable at runtime', () => {
|
||||||
expect(Object.isFrozen(BROKER_OPERATION_TYPE_OPTIONS)).toBe(true);
|
expect(Object.isFrozen(BROKER_OPERATION_TYPE_OPTIONS)).toBe(true)
|
||||||
expect(BROKER_OPERATION_TYPE_OPTIONS.every((option) => Object.isFrozen(option))).toBe(true);
|
expect(BROKER_OPERATION_TYPE_OPTIONS.every((option) => Object.isFrozen(option))).toBe(true)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('validates only exact known operation type values', () => {
|
it('validates only exact known operation type values', () => {
|
||||||
expect(isBrokerOperationType('OPERATION_TYPE_COUPON')).toBe(true);
|
expect(isBrokerOperationType('OPERATION_TYPE_COUPON')).toBe(true)
|
||||||
expect(isBrokerOperationType('operation_type_coupon')).toBe(false);
|
expect(isBrokerOperationType('operation_type_coupon')).toBe(false)
|
||||||
expect(isBrokerOperationType('OPERATION_TYPE_UNKNOWN')).toBe(false);
|
expect(isBrokerOperationType('OPERATION_TYPE_UNKNOWN')).toBe(false)
|
||||||
expect(isBrokerOperationType(null)).toBe(false);
|
expect(isBrokerOperationType(null)).toBe(false)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('classifies operations by portfolio impact', () => {
|
it('classifies operations by portfolio impact', () => {
|
||||||
expect(
|
expect(
|
||||||
@ -158,7 +158,7 @@ describe('broker display helpers', () => {
|
|||||||
payment: { currency: 'RUB', units: '120', nano: 0, value: 120 },
|
payment: { currency: 'RUB', units: '120', nano: 0, value: 120 },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toBe('adds');
|
).toBe('adds')
|
||||||
expect(
|
expect(
|
||||||
getBrokerOperationImpact(
|
getBrokerOperationImpact(
|
||||||
operation({
|
operation({
|
||||||
@ -167,7 +167,7 @@ describe('broker display helpers', () => {
|
|||||||
payment: { currency: 'RUB', units: '-13', nano: 0, value: -13 },
|
payment: { currency: 'RUB', units: '-13', nano: 0, value: -13 },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toBe('reduces');
|
).toBe('reduces')
|
||||||
expect(
|
expect(
|
||||||
getBrokerOperationImpact(
|
getBrokerOperationImpact(
|
||||||
operation({
|
operation({
|
||||||
@ -176,11 +176,11 @@ describe('broker display helpers', () => {
|
|||||||
payment: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
|
payment: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toBe('neutral');
|
).toBe('neutral')
|
||||||
expect(getBrokerOperationImpact(operation({ type: 'OPERATION_TYPE_UNSPECIFIED' }))).toBe(
|
expect(getBrokerOperationImpact(operation({ type: 'OPERATION_TYPE_UNSPECIFIED' }))).toBe(
|
||||||
'unknown',
|
'unknown',
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('keeps unknown operation types unclear even when they have non-zero payments', () => {
|
it('keeps unknown operation types unclear even when they have non-zero payments', () => {
|
||||||
expect(
|
expect(
|
||||||
@ -191,7 +191,7 @@ describe('broker display helpers', () => {
|
|||||||
payment: { currency: 'RUB', units: '100', nano: 0, value: 100 },
|
payment: { currency: 'RUB', units: '100', nano: 0, value: 100 },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toBe('unknown');
|
).toBe('unknown')
|
||||||
expect(
|
expect(
|
||||||
getBrokerOperationImpact(
|
getBrokerOperationImpact(
|
||||||
operation({
|
operation({
|
||||||
@ -200,8 +200,8 @@ describe('broker display helpers', () => {
|
|||||||
payment: { currency: 'RUB', units: '-100', nano: 0, value: -100 },
|
payment: { currency: 'RUB', units: '-100', nano: 0, value: -100 },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toBe('unknown');
|
).toBe('unknown')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('classifies known income operation types as additions even with weak metadata', () => {
|
it('classifies known income operation types as additions even with weak metadata', () => {
|
||||||
expect(
|
expect(
|
||||||
@ -212,7 +212,7 @@ describe('broker display helpers', () => {
|
|||||||
payment: null,
|
payment: null,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toBe('adds');
|
).toBe('adds')
|
||||||
expect(
|
expect(
|
||||||
getBrokerOperationImpact(
|
getBrokerOperationImpact(
|
||||||
operation({
|
operation({
|
||||||
@ -221,6 +221,6 @@ describe('broker display helpers', () => {
|
|||||||
payment: null,
|
payment: null,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toBe('adds');
|
).toBe('adds')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@ -1,46 +1,46 @@
|
|||||||
import type { BrokerPosition } from '@/shared/api/responses';
|
import type { BrokerPosition } from '@/shared/api/responses'
|
||||||
|
|
||||||
export type BrokerPositionGroup = 'shares' | 'bonds' | 'other';
|
export type BrokerPositionGroup = 'shares' | 'bonds' | 'other'
|
||||||
|
|
||||||
type BrokerInstrumentLinkInput = {
|
type BrokerInstrumentLinkInput = {
|
||||||
ticker: string | null;
|
ticker: string | null
|
||||||
instrumentType: string | null;
|
instrumentType: string | null
|
||||||
classCode: string | null;
|
classCode: string | null
|
||||||
};
|
}
|
||||||
|
|
||||||
const STOCK_CLASS_CODES = new Set(['TQBR']);
|
const STOCK_CLASS_CODES = new Set(['TQBR'])
|
||||||
const BOND_CLASS_CODES = new Set(['TQOB', 'TQCB', 'TQIR']);
|
const BOND_CLASS_CODES = new Set(['TQOB', 'TQCB', 'TQIR'])
|
||||||
|
|
||||||
export function getBrokerPositionGroup(
|
export function getBrokerPositionGroup(
|
||||||
position: Pick<BrokerPosition, 'instrumentType'>,
|
position: Pick<BrokerPosition, 'instrumentType'>,
|
||||||
): BrokerPositionGroup {
|
): BrokerPositionGroup {
|
||||||
const instrumentType = position.instrumentType?.toLowerCase();
|
const instrumentType = position.instrumentType?.toLowerCase()
|
||||||
|
|
||||||
if (instrumentType === 'share') return 'shares';
|
if (instrumentType === 'share') return 'shares'
|
||||||
if (instrumentType === 'bond') return 'bonds';
|
if (instrumentType === 'bond') return 'bonds'
|
||||||
|
|
||||||
return 'other';
|
return 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBrokerInstrumentPath(input: BrokerInstrumentLinkInput): string | null {
|
export function getBrokerInstrumentPath(input: BrokerInstrumentLinkInput): string | null {
|
||||||
const ticker = input.ticker?.trim().toUpperCase();
|
const ticker = input.ticker?.trim().toUpperCase()
|
||||||
if (!ticker) return null;
|
if (!ticker) return null
|
||||||
|
|
||||||
const instrumentType = input.instrumentType?.toLowerCase();
|
const instrumentType = input.instrumentType?.toLowerCase()
|
||||||
const classCode = input.classCode?.toUpperCase() ?? null;
|
const classCode = input.classCode?.toUpperCase() ?? null
|
||||||
|
|
||||||
if (instrumentType === 'share') {
|
if (instrumentType === 'share') {
|
||||||
return `/stocks/${encodeURIComponent(ticker)}`;
|
return `/stocks/${encodeURIComponent(ticker)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (instrumentType === 'bond') {
|
if (instrumentType === 'bond') {
|
||||||
return `/bonds/${encodeURIComponent(ticker)}`;
|
return `/bonds/${encodeURIComponent(ticker)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (instrumentType) return null;
|
if (instrumentType) return null
|
||||||
|
|
||||||
if (classCode && STOCK_CLASS_CODES.has(classCode)) return `/stocks/${encodeURIComponent(ticker)}`;
|
if (classCode && STOCK_CLASS_CODES.has(classCode)) return `/stocks/${encodeURIComponent(ticker)}`
|
||||||
if (classCode && BOND_CLASS_CODES.has(classCode)) return `/bonds/${encodeURIComponent(ticker)}`;
|
if (classCode && BOND_CLASS_CODES.has(classCode)) return `/bonds/${encodeURIComponent(ticker)}`
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||||
import type { BrokerPositionsPage } from '@/shared/api/responses';
|
import type { BrokerPositionsPage } from '@/shared/api/responses'
|
||||||
import { getBrokerPositions } from '../api/brokerPositionApi';
|
import { getBrokerPositions } from '../api/brokerPositionApi'
|
||||||
|
|
||||||
export function useBrokerPositions(
|
export function useBrokerPositions(
|
||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
@ -14,5 +14,5 @@ export function useBrokerPositions(
|
|||||||
retry: 2,
|
retry: 2,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,33 +1,33 @@
|
|||||||
import { request } from '@/shared/api/client';
|
import { request } from '@/shared/api/kyClient'
|
||||||
import type {
|
import type {
|
||||||
AnalyticsResponse,
|
AnalyticsResponse,
|
||||||
Portfolio,
|
Portfolio,
|
||||||
PortfolioDetail,
|
PortfolioDetail,
|
||||||
Position,
|
Position,
|
||||||
} from '@/shared/api/responses';
|
} from '@/shared/api/responses'
|
||||||
|
|
||||||
export function getPortfolios(): Promise<{
|
export function getPortfolios(): Promise<{
|
||||||
data: Portfolio[];
|
data: Portfolio[]
|
||||||
meta: { cachedAt: string | null; fromCache: boolean };
|
meta: { cachedAt: string | null; fromCache: boolean }
|
||||||
}> {
|
}> {
|
||||||
return request<Portfolio[]>('/api/v1/portfolios');
|
return request<Portfolio[]>('/api/v1/portfolios')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPortfolio(
|
export function getPortfolio(
|
||||||
id: number,
|
id: number,
|
||||||
): Promise<{ data: PortfolioDetail; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
): Promise<{ data: PortfolioDetail; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||||
return request<PortfolioDetail>(`/api/v1/portfolios/${id}`);
|
return request<PortfolioDetail>(`/api/v1/portfolios/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPortfolio(data: {
|
export function createPortfolio(data: {
|
||||||
name: string;
|
name: string
|
||||||
description?: string;
|
description?: string
|
||||||
currency?: string;
|
currency?: string
|
||||||
}): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
}): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||||
return request<Portfolio>('/api/v1/portfolios', undefined, {
|
return request<Portfolio>('/api/v1/portfolios', undefined, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: data,
|
body: data,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updatePortfolio(
|
export function updatePortfolio(
|
||||||
@ -37,7 +37,7 @@ export function updatePortfolio(
|
|||||||
return request<Portfolio>(`/api/v1/portfolios/${id}`, undefined, {
|
return request<Portfolio>(`/api/v1/portfolios/${id}`, undefined, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: data,
|
body: data,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deletePortfolio(
|
export function deletePortfolio(
|
||||||
@ -45,41 +45,41 @@ export function deletePortfolio(
|
|||||||
): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||||
return request<null>(`/api/v1/portfolios/${id}`, undefined, {
|
return request<null>(`/api/v1/portfolios/${id}`, undefined, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addPosition(
|
export function addPosition(
|
||||||
portfolioId: number,
|
portfolioId: number,
|
||||||
data: {
|
data: {
|
||||||
secid: string;
|
secid: string
|
||||||
quantity: number;
|
quantity: number
|
||||||
buyPrice?: number;
|
buyPrice?: number
|
||||||
buyDate?: string;
|
buyDate?: string
|
||||||
notes?: string;
|
notes?: string
|
||||||
tags?: string[];
|
tags?: string[]
|
||||||
},
|
},
|
||||||
): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||||
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions`, undefined, {
|
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions`, undefined, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: data,
|
body: data,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updatePosition(
|
export function updatePosition(
|
||||||
portfolioId: number,
|
portfolioId: number,
|
||||||
positionId: number,
|
positionId: number,
|
||||||
data: {
|
data: {
|
||||||
quantity?: number;
|
quantity?: number
|
||||||
buyPrice?: number;
|
buyPrice?: number
|
||||||
buyDate?: string;
|
buyDate?: string
|
||||||
notes?: string;
|
notes?: string
|
||||||
tags?: string[];
|
tags?: string[]
|
||||||
},
|
},
|
||||||
): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||||
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, {
|
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: data,
|
body: data,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removePosition(
|
export function removePosition(
|
||||||
@ -88,11 +88,11 @@ export function removePosition(
|
|||||||
): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||||
return request<null>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, {
|
return request<null>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPortfolioAnalytics(
|
export function getPortfolioAnalytics(
|
||||||
portfolioId: number,
|
portfolioId: number,
|
||||||
): Promise<{ data: AnalyticsResponse; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
): Promise<{ data: AnalyticsResponse; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||||
return request<AnalyticsResponse>(`/api/v1/portfolios/${portfolioId}/analytics`);
|
return request<AnalyticsResponse>(`/api/v1/portfolios/${portfolioId}/analytics`)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
export { usePortfolio } from './model/usePortfolio';
|
|
||||||
export { usePortfolios } from './model/usePortfolios';
|
|
||||||
export { usePortfolioAnalytics } from './model/usePortfolioAnalytics';
|
|
||||||
export { usePortfolioMutations } from './model/usePortfolioMutations';
|
|
||||||
export { usePositionMutations } from './model/usePositionMutations';
|
|
||||||
export {
|
export {
|
||||||
getPortfolios,
|
|
||||||
getPortfolio,
|
|
||||||
createPortfolio,
|
|
||||||
updatePortfolio,
|
|
||||||
deletePortfolio,
|
|
||||||
addPosition,
|
addPosition,
|
||||||
updatePosition,
|
createPortfolio,
|
||||||
removePosition,
|
deletePortfolio,
|
||||||
|
getPortfolio,
|
||||||
getPortfolioAnalytics,
|
getPortfolioAnalytics,
|
||||||
} from './api/portfolioApi';
|
getPortfolios,
|
||||||
|
removePosition,
|
||||||
|
updatePortfolio,
|
||||||
|
updatePosition,
|
||||||
|
} from './api/portfolioApi'
|
||||||
|
export { usePortfolio } from './model/usePortfolio'
|
||||||
|
export { usePortfolioAnalytics } from './model/usePortfolioAnalytics'
|
||||||
|
export { usePortfolioMutations } from './model/usePortfolioMutations'
|
||||||
|
export { usePortfolios } from './model/usePortfolios'
|
||||||
|
export { usePositionMutations } from './model/usePositionMutations'
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { getPortfolio } from '../api/portfolioApi';
|
import type { PortfolioDetail } from '@/shared/api/responses'
|
||||||
import type { PortfolioDetail } from '@/shared/api/responses';
|
import { getPortfolio } from '../api/portfolioApi'
|
||||||
|
|
||||||
export function usePortfolio(id: number) {
|
export function usePortfolio(id: number) {
|
||||||
return useQuery<PortfolioDetail>({
|
return useQuery<PortfolioDetail>({
|
||||||
queryKey: ['portfolio', id],
|
queryKey: ['portfolio', id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await getPortfolio(id);
|
const res = await getPortfolio(id)
|
||||||
return res.data;
|
return res.data
|
||||||
},
|
},
|
||||||
staleTime: 900_000,
|
staleTime: 900_000,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { getPortfolioAnalytics } from '../api/portfolioApi';
|
import type { AnalyticsResponse } from '@/shared/api/responses'
|
||||||
import type { AnalyticsResponse } from '@/shared/api/responses';
|
import { getPortfolioAnalytics } from '../api/portfolioApi'
|
||||||
|
|
||||||
export function usePortfolioAnalytics(portfolioId: number) {
|
export function usePortfolioAnalytics(portfolioId: number) {
|
||||||
return useQuery<AnalyticsResponse>({
|
return useQuery<AnalyticsResponse>({
|
||||||
queryKey: ['portfolio', portfolioId, 'analytics'],
|
queryKey: ['portfolio', portfolioId, 'analytics'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await getPortfolioAnalytics(portfolioId);
|
const res = await getPortfolioAnalytics(portfolioId)
|
||||||
return res.data;
|
return res.data
|
||||||
},
|
},
|
||||||
staleTime: 900_000,
|
staleTime: 900_000,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
enabled: !!portfolioId,
|
enabled: !!portfolioId,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,45 +1,45 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { createPortfolio, updatePortfolio, deletePortfolio } from '../api/portfolioApi';
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { createPortfolio, deletePortfolio, updatePortfolio } from '../api/portfolioApi'
|
||||||
|
|
||||||
export function usePortfolioMutations() {
|
export function usePortfolioMutations() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (data: { name: string; description?: string; currency?: string }) =>
|
mutationFn: (data: { name: string; description?: string; currency?: string }) =>
|
||||||
createPortfolio(data),
|
createPortfolio(data),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
queryClient.invalidateQueries({ queryKey: ['portfolios'] })
|
||||||
navigate(`/portfolios/${res.data.id}`);
|
navigate({ to: `/portfolios/${res.data.id}` })
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const update = useMutation({
|
const update = useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
id,
|
id,
|
||||||
data,
|
data,
|
||||||
}: {
|
}: {
|
||||||
id: number;
|
id: number
|
||||||
data: {
|
data: {
|
||||||
name?: string;
|
name?: string
|
||||||
description?: string;
|
description?: string
|
||||||
currency?: string;
|
currency?: string
|
||||||
};
|
}
|
||||||
}) => updatePortfolio(id, data),
|
}) => updatePortfolio(id, data),
|
||||||
onSuccess: (_, { id }) => {
|
onSuccess: (_, { id }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
queryClient.invalidateQueries({ queryKey: ['portfolios'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio', id] });
|
queryClient.invalidateQueries({ queryKey: ['portfolio', id] })
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: number) => deletePortfolio(id),
|
mutationFn: (id: number) => deletePortfolio(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
queryClient.invalidateQueries({ queryKey: ['portfolios'] })
|
||||||
navigate('/portfolios');
|
navigate({ to: '/portfolios' })
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
return { create, update, remove };
|
return { create, update, remove }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { getPortfolios } from '../api/portfolioApi';
|
import type { Portfolio } from '@/shared/api/responses'
|
||||||
import type { Portfolio } from '@/shared/api/responses';
|
import { getPortfolios } from '../api/portfolioApi'
|
||||||
|
|
||||||
export function usePortfolios() {
|
export function usePortfolios() {
|
||||||
return useQuery<Portfolio[]>({
|
return useQuery<Portfolio[]>({
|
||||||
queryKey: ['portfolios'],
|
queryKey: ['portfolios'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await getPortfolios();
|
const res = await getPortfolios()
|
||||||
return res.data;
|
return res.data
|
||||||
},
|
},
|
||||||
staleTime: 900_000,
|
staleTime: 900_000,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,46 +1,46 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { addPosition, updatePosition, removePosition } from '../api/portfolioApi';
|
import type { PortfolioDetail } from '@/shared/api/responses'
|
||||||
import type { PortfolioDetail } from '@/shared/api/responses';
|
import { addPosition, removePosition, updatePosition } from '../api/portfolioApi'
|
||||||
|
|
||||||
export function usePositionMutations(portfolioId: number) {
|
export function usePositionMutations(portfolioId: number) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const add = useMutation({
|
const add = useMutation({
|
||||||
mutationFn: (data: {
|
mutationFn: (data: {
|
||||||
secid: string;
|
secid: string
|
||||||
quantity: number;
|
quantity: number
|
||||||
buyPrice?: number;
|
buyPrice?: number
|
||||||
buyDate?: string;
|
buyDate?: string
|
||||||
notes?: string;
|
notes?: string
|
||||||
tags?: string[];
|
tags?: string[]
|
||||||
}) => addPosition(portfolioId, data),
|
}) => addPosition(portfolioId, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
|
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] })
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const update = useMutation({
|
const update = useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
positionId,
|
positionId,
|
||||||
data,
|
data,
|
||||||
}: {
|
}: {
|
||||||
positionId: number;
|
positionId: number
|
||||||
data: {
|
data: {
|
||||||
quantity?: number;
|
quantity?: number
|
||||||
buyPrice?: number;
|
buyPrice?: number
|
||||||
buyDate?: string;
|
buyDate?: string
|
||||||
notes?: string;
|
notes?: string
|
||||||
tags?: string[];
|
tags?: string[]
|
||||||
};
|
}
|
||||||
}) => updatePosition(portfolioId, positionId, data),
|
}) => updatePosition(portfolioId, positionId, data),
|
||||||
onMutate: async ({ positionId, data }) => {
|
onMutate: async ({ positionId, data }) => {
|
||||||
await queryClient.cancelQueries({ queryKey: ['portfolio', portfolioId] });
|
await queryClient.cancelQueries({ queryKey: ['portfolio', portfolioId] })
|
||||||
const previous = queryClient.getQueryData<{ data: PortfolioDetail }>([
|
const previous = queryClient.getQueryData<{ data: PortfolioDetail }>([
|
||||||
'portfolio',
|
'portfolio',
|
||||||
portfolioId,
|
portfolioId,
|
||||||
]);
|
])
|
||||||
queryClient.setQueryData(['portfolio', portfolioId], (old: any) => {
|
queryClient.setQueryData(['portfolio', portfolioId], (old: any) => {
|
||||||
if (!old) return old;
|
if (!old) return old
|
||||||
return {
|
return {
|
||||||
...old,
|
...old,
|
||||||
positions: old.positions.map((p: any) =>
|
positions: old.positions.map((p: any) =>
|
||||||
@ -53,26 +53,26 @@ export function usePositionMutations(portfolioId: number) {
|
|||||||
}
|
}
|
||||||
: p,
|
: p,
|
||||||
),
|
),
|
||||||
};
|
}
|
||||||
});
|
})
|
||||||
return { previous };
|
return { previous }
|
||||||
},
|
},
|
||||||
onError: (_err, _vars, context) => {
|
onError: (_err, _vars, context) => {
|
||||||
if (context?.previous) {
|
if (context?.previous) {
|
||||||
queryClient.setQueryData(['portfolio', portfolioId], context.previous);
|
queryClient.setQueryData(['portfolio', portfolioId], context.previous)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
|
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] })
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (positionId: number) => removePosition(portfolioId, positionId),
|
mutationFn: (positionId: number) => removePosition(portfolioId, positionId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
|
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] })
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
return { add, update, remove };
|
return { add, update, remove }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { request } from '@/shared/api/client';
|
import { request } from '@/shared/api/kyClient'
|
||||||
import type { SearchResultItem } from '@/shared/api/responses';
|
import type { SearchResultItem } from '@/shared/api/responses'
|
||||||
|
|
||||||
export function searchSecurities(q: string, type: 'all' | 'share' | 'bond' = 'all', limit = 20) {
|
export function searchSecurities(q: string, type: 'all' | 'share' | 'bond' = 'all', limit = 20) {
|
||||||
return request<SearchResultItem[]>('/api/v1/securities/search', {
|
return request<SearchResultItem[]>('/api/v1/securities/search', {
|
||||||
q,
|
q,
|
||||||
type,
|
type,
|
||||||
limit: String(limit),
|
limit: String(limit),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
export { useSearch } from './model/useSearch';
|
export { searchSecurities } from './api/searchApi'
|
||||||
export { searchSecurities } from './api/searchApi';
|
export { useSearch } from './model/useSearch'
|
||||||
|
|||||||
@ -1,46 +1,46 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { HttpResponse, http } from 'msw'
|
||||||
import { http, HttpResponse } from 'msw';
|
import type { ReactNode } from 'react'
|
||||||
import { type ReactNode } from 'react';
|
import { describe, expect, it } from 'vitest'
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { useSearch } from '@/entities/search'
|
||||||
import { useSearch } from '@/entities/search';
|
import { server } from '@/shared/lib/test/server'
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1'
|
||||||
|
|
||||||
function createWrapper() {
|
function createWrapper() {
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
|
||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('useSearch', () => {
|
describe('useSearch', () => {
|
||||||
it('does not fetch when query is empty', () => {
|
it('does not fetch when query is empty', () => {
|
||||||
const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() })
|
||||||
|
|
||||||
expect(result.current.isFetching).toBe(false);
|
expect(result.current.isFetching).toBe(false)
|
||||||
expect(result.current.data).toBeUndefined();
|
expect(result.current.data).toBeUndefined()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('does not fetch when query is too short', () => {
|
it('does not fetch when query is too short', () => {
|
||||||
const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() })
|
||||||
|
|
||||||
expect(result.current.data).toBeUndefined();
|
expect(result.current.data).toBeUndefined()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('returns search results for valid query', async () => {
|
it('returns search results for valid query', async () => {
|
||||||
const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() })
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.isSuccess).toBe(true);
|
expect(result.current.isSuccess).toBe(true)
|
||||||
});
|
})
|
||||||
|
|
||||||
expect(result.current.data).toBeDefined();
|
expect(result.current.data).toBeDefined()
|
||||||
expect(result.current.data?.length).toBeGreaterThan(0);
|
expect(result.current.data?.length).toBeGreaterThan(0)
|
||||||
expect(result.current.data?.[0].secid).toBe('SBER');
|
expect(result.current.data?.[0].secid).toBe('SBER')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('returns empty array when no results', async () => {
|
it('returns empty array when no results', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
@ -49,24 +49,24 @@ describe('useSearch', () => {
|
|||||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
|
|
||||||
const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() })
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.isSuccess).toBe(true);
|
expect(result.current.isSuccess).toBe(true)
|
||||||
});
|
})
|
||||||
|
|
||||||
expect(result.current.data).toEqual([]);
|
expect(result.current.data).toEqual([])
|
||||||
});
|
})
|
||||||
|
|
||||||
it('returns error state on network failure', async () => {
|
it('returns error state on network failure', async () => {
|
||||||
server.use(http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 })));
|
server.use(http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 })))
|
||||||
|
|
||||||
const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() })
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.isError).toBe(true);
|
expect(result.current.isError).toBe(true)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { searchSecurities } from '../api/searchApi';
|
import type { SearchResultItem } from '@/shared/api/responses'
|
||||||
import type { SearchResultItem } from '@/shared/api/responses';
|
import { searchSecurities } from '../api/searchApi'
|
||||||
|
|
||||||
export function useSearch(query: string) {
|
export function useSearch(query: string) {
|
||||||
return useQuery<SearchResultItem[]>({
|
return useQuery<SearchResultItem[]>({
|
||||||
queryKey: ['securities', 'search', query],
|
queryKey: ['securities', 'search', query],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await searchSecurities(query);
|
const res = await searchSecurities(query)
|
||||||
|
|
||||||
return res.data;
|
return res.data
|
||||||
},
|
},
|
||||||
enabled: query.length >= 2,
|
enabled: query.length >= 2,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { HttpResponse, http } from 'msw'
|
||||||
import { http, HttpResponse } from 'msw';
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { server } from '@/shared/lib/test/server'
|
||||||
import { setAccessToken, getAccessToken } from './tokenManager';
|
import { getMe, login, logout, refresh, register, updateProfile } from './sessionApi'
|
||||||
import { login, register, refresh, logout, getMe, updateProfile } from './sessionApi';
|
import { getAccessToken, setAccessToken } from './tokenManager'
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1'
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setAccessToken(null);
|
setAccessToken(null)
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
it('returns auth data and sets access token', async () => {
|
it('returns auth data and sets access token', async () => {
|
||||||
const result = await login('user@test.com', 'password');
|
const result = await login('user@test.com', 'password')
|
||||||
expect(result.user.email).toBe('user@test.com');
|
expect(result.user.email).toBe('user@test.com')
|
||||||
expect(result.accessToken).toBe('mock-access-token');
|
expect(result.accessToken).toBe('mock-access-token')
|
||||||
expect(getAccessToken()).toBe('mock-access-token');
|
expect(getAccessToken()).toBe('mock-access-token')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('throws on invalid credentials', async () => {
|
it('throws on invalid credentials', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
@ -24,45 +24,45 @@ describe('login', () => {
|
|||||||
`${API}/auth/login`,
|
`${API}/auth/login`,
|
||||||
() => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }),
|
() => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }),
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
await expect(login('wrong@test.com', 'wrong')).rejects.toThrow();
|
await expect(login('wrong@test.com', 'wrong')).rejects.toThrow()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('register', () => {
|
describe('register', () => {
|
||||||
it('returns auth data and sets access token', async () => {
|
it('returns auth data and sets access token', async () => {
|
||||||
const result = await register('new@test.com', 'password', 'New User');
|
const result = await register('new@test.com', 'password', 'New User')
|
||||||
expect(result.user.email).toBe('user@test.com');
|
expect(result.user.email).toBe('user@test.com')
|
||||||
expect(getAccessToken()).toBe('mock-access-token');
|
expect(getAccessToken()).toBe('mock-access-token')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('refresh', () => {
|
describe('refresh', () => {
|
||||||
it('returns auth data and sets access token', async () => {
|
it('returns auth data and sets access token', async () => {
|
||||||
const result = await refresh();
|
const result = await refresh()
|
||||||
expect(result.accessToken).toBe('mock-access-token');
|
expect(result.accessToken).toBe('mock-access-token')
|
||||||
expect(getAccessToken()).toBe('mock-access-token');
|
expect(getAccessToken()).toBe('mock-access-token')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('logout', () => {
|
describe('logout', () => {
|
||||||
it('clears access token', async () => {
|
it('clears access token', async () => {
|
||||||
setAccessToken('test-token');
|
setAccessToken('test-token')
|
||||||
await logout();
|
await logout()
|
||||||
expect(getAccessToken()).toBeNull();
|
expect(getAccessToken()).toBeNull()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('getMe', () => {
|
describe('getMe', () => {
|
||||||
it('returns current user', async () => {
|
it('returns current user', async () => {
|
||||||
const result = await getMe();
|
const result = await getMe()
|
||||||
expect(result.email).toBe('user@test.com');
|
expect(result.email).toBe('user@test.com')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe('updateProfile', () => {
|
describe('updateProfile', () => {
|
||||||
it('updates and returns user', async () => {
|
it('updates and returns user', async () => {
|
||||||
const result = await updateProfile({ name: 'Updated' });
|
const result = await updateProfile({ name: 'Updated' })
|
||||||
expect(result.name).toBe('Updated');
|
expect(result.name).toBe('Updated')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
import { request } from '@/shared/api/client';
|
import { request } from '@/shared/api/kyClient'
|
||||||
import { setAccessToken } from './tokenManager';
|
import type { AuthResponse, UserResponse } from '@/shared/api/responses'
|
||||||
import type { AuthResponse, UserResponse } from '@/shared/api/responses';
|
import { setAccessToken } from './tokenManager'
|
||||||
|
|
||||||
export async function login(email: string, password: string) {
|
export async function login(email: string, password: string) {
|
||||||
const result = await request<AuthResponse>('/api/v1/auth/login', undefined, {
|
const result = await request<AuthResponse>('/api/v1/auth/login', undefined, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { email, password },
|
body: { email, password },
|
||||||
skipAuth: true,
|
skipAuth: true,
|
||||||
});
|
})
|
||||||
setAccessToken(result.data.accessToken);
|
setAccessToken(result.data.accessToken)
|
||||||
return result.data;
|
return result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function register(email: string, password: string, name?: string) {
|
export async function register(email: string, password: string, name?: string) {
|
||||||
@ -17,37 +17,37 @@ export async function register(email: string, password: string, name?: string) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { email, password, name },
|
body: { email, password, name },
|
||||||
skipAuth: true,
|
skipAuth: true,
|
||||||
});
|
})
|
||||||
setAccessToken(result.data.accessToken);
|
setAccessToken(result.data.accessToken)
|
||||||
return result.data;
|
return result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refresh() {
|
export async function refresh() {
|
||||||
const result = await request<AuthResponse>('/api/v1/auth/refresh', undefined, {
|
const result = await request<AuthResponse>('/api/v1/auth/refresh', undefined, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
skipAuth: true,
|
skipAuth: true,
|
||||||
});
|
})
|
||||||
setAccessToken(result.data.accessToken);
|
setAccessToken(result.data.accessToken)
|
||||||
return result.data;
|
return result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout() {
|
export async function logout() {
|
||||||
const result = await request<{ message: string }>('/api/v1/auth/logout', undefined, {
|
const result = await request<{ message: string }>('/api/v1/auth/logout', undefined, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
})
|
||||||
setAccessToken(null);
|
setAccessToken(null)
|
||||||
return result.data;
|
return result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMe() {
|
export async function getMe() {
|
||||||
const result = await request<UserResponse>('/api/v1/auth/me');
|
const result = await request<UserResponse>('/api/v1/auth/me')
|
||||||
return result.data;
|
return result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProfile(data: { name?: string }) {
|
export async function updateProfile(data: { name?: string }) {
|
||||||
const result = await request<UserResponse>('/api/v1/auth/me', undefined, {
|
const result = await request<UserResponse>('/api/v1/auth/me', undefined, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: data,
|
body: data,
|
||||||
});
|
})
|
||||||
return result.data;
|
return result.data
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,21 @@
|
|||||||
import type { AuthResponse } from '@/shared/api/responses';
|
import { normalizeEnvelope } from '@/shared/api/kyClient'
|
||||||
import { normalizeEnvelope } from '@/shared/api/client';
|
import type { AuthResponse } from '@/shared/api/responses'
|
||||||
|
|
||||||
let accessToken: string | null = null;
|
let accessToken: string | null = null
|
||||||
let onUnauthorized: (() => void) | null = null;
|
let onUnauthorized: (() => void) | null = null
|
||||||
let isRefreshing = false;
|
let isRefreshing = false
|
||||||
let refreshPromise: Promise<boolean> | null = null;
|
let refreshPromise: Promise<boolean> | null = null
|
||||||
|
|
||||||
export function setAccessToken(token: string | null) {
|
export function setAccessToken(token: string | null) {
|
||||||
accessToken = token;
|
accessToken = token
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAccessToken(): string | null {
|
export function getAccessToken(): string | null {
|
||||||
return accessToken;
|
return accessToken
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setOnUnauthorized(cb: () => void) {
|
export function setOnUnauthorized(cb: () => void) {
|
||||||
onUnauthorized = cb;
|
onUnauthorized = cb
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshTokens(): Promise<boolean> {
|
async function refreshTokens(): Promise<boolean> {
|
||||||
@ -23,31 +23,31 @@ async function refreshTokens(): Promise<boolean> {
|
|||||||
const res = await fetch('/api/v1/auth/refresh', {
|
const res = await fetch('/api/v1/auth/refresh', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
})
|
||||||
if (!res.ok) return false;
|
if (!res.ok) return false
|
||||||
const json = await res.json();
|
const json = await res.json()
|
||||||
accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
|
accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken
|
||||||
return true;
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleUnauthorized(): Promise<boolean> {
|
export async function handleUnauthorized(): Promise<boolean> {
|
||||||
if (isRefreshing && refreshPromise) {
|
if (isRefreshing && refreshPromise) {
|
||||||
return refreshPromise;
|
return refreshPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
isRefreshing = true;
|
isRefreshing = true
|
||||||
refreshPromise = refreshTokens().then((success) => {
|
refreshPromise = refreshTokens().then((success) => {
|
||||||
isRefreshing = false;
|
isRefreshing = false
|
||||||
refreshPromise = null;
|
refreshPromise = null
|
||||||
if (!success) {
|
if (!success) {
|
||||||
accessToken = null;
|
accessToken = null
|
||||||
onUnauthorized?.();
|
onUnauthorized?.()
|
||||||
}
|
}
|
||||||
return success;
|
return success
|
||||||
});
|
})
|
||||||
|
|
||||||
return refreshPromise;
|
return refreshPromise
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export { login, register, refresh, logout, getMe, updateProfile } from './api/sessionApi';
|
export { getMe, login, logout, refresh, register, updateProfile } from './api/sessionApi'
|
||||||
export { SessionContext, type SessionContextValue } from './model/sessionContext';
|
export { SessionContext, type SessionContextValue } from './model/sessionContext'
|
||||||
export { useSession } from './model/useSession';
|
export { useSession } from './model/useSession'
|
||||||
|
export { useSessionStore } from './model/useSessionStore'
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { SessionContext, type SessionContextValue } from '@/shared/lib/session-context';
|
export { SessionContext, type SessionContextValue } from '@/shared/lib/session-context'
|
||||||
|
|||||||
@ -1,20 +1,20 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { renderHook } from '@testing-library/react'
|
||||||
import { renderHook } from '@testing-library/react';
|
import type { ReactNode } from 'react'
|
||||||
import { SessionContext } from './sessionContext';
|
import { describe, expect, it } from 'vitest'
|
||||||
import { useSession } from './useSession';
|
import { SessionContext } from './sessionContext'
|
||||||
import type { ReactNode } from 'react';
|
import { useSession } from './useSession'
|
||||||
|
|
||||||
type SessionState = {
|
type SessionState = {
|
||||||
user: { id: number; email: string; name: string; role: string } | null;
|
user: { id: number; email: string; name: string; role: string } | null
|
||||||
accessToken: string | null;
|
accessToken: string | null
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean
|
||||||
isLoading: boolean;
|
isLoading: boolean
|
||||||
login: () => Promise<void>;
|
login: () => Promise<void>
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>
|
||||||
register: () => Promise<void>;
|
register: () => Promise<void>
|
||||||
updateProfile: () => Promise<void>;
|
updateProfile: () => Promise<void>
|
||||||
refreshSession: () => Promise<void>;
|
refreshSession: () => Promise<void>
|
||||||
};
|
}
|
||||||
|
|
||||||
const mockSession: SessionState = {
|
const mockSession: SessionState = {
|
||||||
user: { id: 1, email: 'user@test.com', name: 'Test User', role: 'user' },
|
user: { id: 1, email: 'user@test.com', name: 'Test User', role: 'user' },
|
||||||
@ -26,34 +26,34 @@ const mockSession: SessionState = {
|
|||||||
register: vi.fn().mockResolvedValue(undefined),
|
register: vi.fn().mockResolvedValue(undefined),
|
||||||
updateProfile: vi.fn().mockResolvedValue(undefined),
|
updateProfile: vi.fn().mockResolvedValue(undefined),
|
||||||
refreshSession: vi.fn().mockResolvedValue(undefined),
|
refreshSession: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
}
|
||||||
|
|
||||||
function createWrapper(session: SessionState = mockSession) {
|
function createWrapper(session: SessionState = mockSession) {
|
||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
return <SessionContext.Provider value={session}>{children}</SessionContext.Provider>;
|
return <SessionContext.Provider value={session}>{children}</SessionContext.Provider>
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('useSession', () => {
|
describe('useSession', () => {
|
||||||
it('returns session context with user', () => {
|
it('returns session context with user', () => {
|
||||||
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() })
|
||||||
expect(result.current.isAuthenticated).toBe(true);
|
expect(result.current.isAuthenticated).toBe(true)
|
||||||
expect(result.current.user?.email).toBe('user@test.com');
|
expect(result.current.user?.email).toBe('user@test.com')
|
||||||
expect(result.current.accessToken).toBe('mock-access-token');
|
expect(result.current.accessToken).toBe('mock-access-token')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('provides login function', () => {
|
it('provides login function', () => {
|
||||||
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() })
|
||||||
expect(typeof result.current.login).toBe('function');
|
expect(typeof result.current.login).toBe('function')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('provides logout function', () => {
|
it('provides logout function', () => {
|
||||||
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() })
|
||||||
expect(typeof result.current.logout).toBe('function');
|
expect(typeof result.current.logout).toBe('function')
|
||||||
});
|
})
|
||||||
|
|
||||||
it('provides register function', () => {
|
it('provides register function', () => {
|
||||||
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() })
|
||||||
expect(typeof result.current.register).toBe('function');
|
expect(typeof result.current.register).toBe('function')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useContext } from 'react';
|
import { useContext } from 'react'
|
||||||
import { SessionContext, type SessionContextValue } from './sessionContext';
|
import { SessionContext, type SessionContextValue } from './sessionContext'
|
||||||
|
|
||||||
export function useSession(): SessionContextValue {
|
export function useSession(): SessionContextValue {
|
||||||
const ctx = useContext(SessionContext);
|
const ctx = useContext(SessionContext)
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error('useSession must be used within a SessionProvider');
|
throw new Error('useSession must be used within a SessionProvider')
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand'
|
||||||
import type { UserResponse } from '@/shared/api/responses';
|
import type { UserResponse } from '@/shared/api/responses'
|
||||||
|
|
||||||
interface SessionState {
|
interface SessionState {
|
||||||
user: UserResponse | null;
|
user: UserResponse | null
|
||||||
accessToken: string | null;
|
accessToken: string | null
|
||||||
isLoading: boolean;
|
isLoading: boolean
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean
|
||||||
setSession: (authData: { user: UserResponse; accessToken: string }) => void;
|
setSession: (authData: { user: UserResponse; accessToken: string }) => void
|
||||||
clearSession: () => void;
|
clearSession: () => void
|
||||||
setLoading: (isLoading: boolean) => void;
|
setLoading: (isLoading: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSessionStore = create<SessionState>((set) => ({
|
export const useSessionStore = create<SessionState>((set) => ({
|
||||||
@ -22,7 +22,7 @@ export const useSessionStore = create<SessionState>((set) => ({
|
|||||||
accessToken: authData.accessToken,
|
accessToken: authData.accessToken,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
})
|
||||||
},
|
},
|
||||||
clearSession: () => {
|
clearSession: () => {
|
||||||
set({
|
set({
|
||||||
@ -30,9 +30,9 @@ export const useSessionStore = create<SessionState>((set) => ({
|
|||||||
accessToken: null,
|
accessToken: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
})
|
||||||
},
|
},
|
||||||
setLoading: (isLoading) => {
|
setLoading: (isLoading) => {
|
||||||
set({ isLoading });
|
set({ isLoading })
|
||||||
},
|
},
|
||||||
}));
|
}))
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
import { request } from '@/shared/api/client';
|
import { request } from '@/shared/api/kyClient'
|
||||||
import type {
|
import type {
|
||||||
ApiResponseMeta,
|
ApiResponseMeta,
|
||||||
ShareResponse,
|
CandleItem,
|
||||||
StockMarketData,
|
|
||||||
DividendItem,
|
DividendItem,
|
||||||
ShareHistoryItem,
|
ShareHistoryItem,
|
||||||
CandleItem,
|
ShareResponse,
|
||||||
} from '@/shared/api/responses';
|
StockMarketData,
|
||||||
|
} from '@/shared/api/responses'
|
||||||
|
|
||||||
export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> {
|
export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> {
|
||||||
return request<ShareResponse>(`/api/v1/securities/shares/${encodeURIComponent(secid)}`);
|
return request<ShareResponse>(`/api/v1/securities/shares/${encodeURIComponent(secid)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getShareMarketData(
|
export function getShareMarketData(
|
||||||
@ -17,15 +17,13 @@ export function getShareMarketData(
|
|||||||
): Promise<{ data: StockMarketData; meta: ApiResponseMeta }> {
|
): Promise<{ data: StockMarketData; meta: ApiResponseMeta }> {
|
||||||
return request<StockMarketData>(
|
return request<StockMarketData>(
|
||||||
`/api/v1/securities/shares/${encodeURIComponent(secid)}/marketdata`,
|
`/api/v1/securities/shares/${encodeURIComponent(secid)}/marketdata`,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getShareDividends(
|
export function getShareDividends(
|
||||||
secid: string,
|
secid: string,
|
||||||
): Promise<{ data: DividendItem[]; meta: ApiResponseMeta }> {
|
): Promise<{ data: DividendItem[]; meta: ApiResponseMeta }> {
|
||||||
return request<DividendItem[]>(
|
return request<DividendItem[]>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/dividends`)
|
||||||
`/api/v1/securities/shares/${encodeURIComponent(secid)}/dividends`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getShareHistory(
|
export function getShareHistory(
|
||||||
@ -36,7 +34,7 @@ export function getShareHistory(
|
|||||||
return request<ShareHistoryItem[]>(
|
return request<ShareHistoryItem[]>(
|
||||||
`/api/v1/securities/shares/${encodeURIComponent(secid)}/history`,
|
`/api/v1/securities/shares/${encodeURIComponent(secid)}/history`,
|
||||||
{ from, till },
|
{ from, till },
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getShareCandles(
|
export function getShareCandles(
|
||||||
@ -49,5 +47,5 @@ export function getShareCandles(
|
|||||||
interval,
|
interval,
|
||||||
from,
|
from,
|
||||||
till,
|
till,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
export { useStock } from './model/useStock';
|
export { useStock } from './model/useStock'
|
||||||
export { useStockCandles } from './model/useStockCandles';
|
export { useStockCandles } from './model/useStockCandles'
|
||||||
export { useStockDividends } from './model/useStockDividends';
|
export { useStockDividends } from './model/useStockDividends'
|
||||||
|
|||||||
@ -1,32 +1,32 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import type { ReactNode } from 'react'
|
||||||
import { useStock } from './useStock';
|
import { describe, expect, it } from 'vitest'
|
||||||
import { type ReactNode } from 'react';
|
import { useStock } from './useStock'
|
||||||
|
|
||||||
function createWrapper() {
|
function createWrapper() {
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('useStock', () => {
|
describe('useStock', () => {
|
||||||
it('returns share data', async () => {
|
it('returns share data', async () => {
|
||||||
const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() })
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
expect(result.current.data?.secid).toBe('SBER');
|
expect(result.current.data?.secid).toBe('SBER')
|
||||||
expect(result.current.data?.shortName).toBe('Сбер');
|
expect(result.current.data?.shortName).toBe('Сбер')
|
||||||
expect(result.current.data?.marketData.price).toBe(289.5);
|
expect(result.current.data?.marketData.price).toBe(289.5)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('returns error for not found', async () => {
|
it('returns error for not found', async () => {
|
||||||
const { result } = renderHook(() => useStock('NOTFOUND'), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useStock('NOTFOUND'), { wrapper: createWrapper() })
|
||||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||||
});
|
})
|
||||||
|
|
||||||
it('starts in loading state', () => {
|
it('starts in loading state', () => {
|
||||||
const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() })
|
||||||
expect(result.current.isLoading).toBe(true);
|
expect(result.current.isLoading).toBe(true)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { getShare } from '../api/stockApi';
|
import type { ShareResponse } from '@/shared/api/responses'
|
||||||
import type { ShareResponse } from '@/shared/api/responses';
|
import { getShare } from '../api/stockApi'
|
||||||
|
|
||||||
export function useStock(secid: string) {
|
export function useStock(secid: string) {
|
||||||
return useQuery<ShareResponse>({
|
return useQuery<ShareResponse>({
|
||||||
queryKey: ['stock', secid],
|
queryKey: ['stock', secid],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await getShare(secid);
|
const res = await getShare(secid)
|
||||||
return res.data;
|
return res.data
|
||||||
},
|
},
|
||||||
staleTime: 900_000,
|
staleTime: 900_000,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { HttpResponse, http } from 'msw'
|
||||||
import { http, HttpResponse } from 'msw';
|
import type { ReactNode } from 'react'
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { describe, expect, it } from 'vitest'
|
||||||
import { useStockCandles } from './useStockCandles';
|
import { server } from '@/shared/lib/test/server'
|
||||||
import { type ReactNode } from 'react';
|
import { useStockCandles } from './useStockCandles'
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1'
|
||||||
|
|
||||||
function createWrapper() {
|
function createWrapper() {
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('useStockCandles', () => {
|
describe('useStockCandles', () => {
|
||||||
@ -20,25 +20,25 @@ describe('useStockCandles', () => {
|
|||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'),
|
() => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'),
|
||||||
{ wrapper: createWrapper() },
|
{ wrapper: createWrapper() },
|
||||||
);
|
)
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
expect(result.current.data).toHaveLength(2);
|
expect(result.current.data).toHaveLength(2)
|
||||||
expect(result.current.data?.[0].open).toBe(280);
|
expect(result.current.data?.[0].open).toBe(280)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('returns empty array when no candles', async () => {
|
it('returns empty array when no candles', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`${API}/securities/shares/:secid/candles`, () => {
|
http.get(`${API}/securities/shares/:secid/candles`, () => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
||||||
});
|
})
|
||||||
}),
|
}),
|
||||||
);
|
)
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'),
|
() => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'),
|
||||||
{ wrapper: createWrapper() },
|
{ wrapper: createWrapper() },
|
||||||
);
|
)
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
expect(result.current.data).toEqual([]);
|
expect(result.current.data).toEqual([])
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { getShareCandles } from '../api/stockApi';
|
import type { CandleItem } from '@/shared/api/responses'
|
||||||
import type { CandleItem } from '@/shared/api/responses';
|
import { getShareCandles } from '../api/stockApi'
|
||||||
|
|
||||||
export function useStockCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {
|
export function useStockCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {
|
||||||
return useQuery<CandleItem[]>({
|
return useQuery<CandleItem[]>({
|
||||||
queryKey: ['stockCandles', secid, interval, from, till],
|
queryKey: ['stockCandles', secid, interval, from, till],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await getShareCandles(secid, interval, from, till);
|
const res = await getShareCandles(secid, interval, from, till)
|
||||||
return res.data;
|
return res.data
|
||||||
},
|
},
|
||||||
staleTime: 3600_000,
|
staleTime: 3600_000,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,38 +1,38 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { HttpResponse, http } from 'msw'
|
||||||
import { http, HttpResponse } from 'msw';
|
import type { ReactNode } from 'react'
|
||||||
import { server } from '@/shared/lib/test/server';
|
import { describe, expect, it } from 'vitest'
|
||||||
import { useStockDividends } from './useStockDividends';
|
import { server } from '@/shared/lib/test/server'
|
||||||
import { type ReactNode } from 'react';
|
import { useStockDividends } from './useStockDividends'
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1'
|
||||||
|
|
||||||
function createWrapper() {
|
function createWrapper() {
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('useStockDividends', () => {
|
describe('useStockDividends', () => {
|
||||||
it('returns dividend data', async () => {
|
it('returns dividend data', async () => {
|
||||||
const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() })
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
expect(result.current.data).toHaveLength(2);
|
expect(result.current.data).toHaveLength(2)
|
||||||
expect(result.current.data?.[0].value).toBe(35);
|
expect(result.current.data?.[0].value).toBe(35)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('returns empty array when no dividends', async () => {
|
it('returns empty array when no dividends', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`${API}/securities/shares/:secid/dividends`, () => {
|
http.get(`${API}/securities/shares/:secid/dividends`, () => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
||||||
});
|
})
|
||||||
}),
|
}),
|
||||||
);
|
)
|
||||||
const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() });
|
const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() })
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
expect(result.current.data).toEqual([]);
|
expect(result.current.data).toEqual([])
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { getShareDividends } from '../api/stockApi';
|
import type { DividendItem } from '@/shared/api/responses'
|
||||||
import type { DividendItem } from '@/shared/api/responses';
|
import { getShareDividends } from '../api/stockApi'
|
||||||
|
|
||||||
export function useStockDividends(secid: string) {
|
export function useStockDividends(secid: string) {
|
||||||
return useQuery<DividendItem[]>({
|
return useQuery<DividendItem[]>({
|
||||||
queryKey: ['stockDividends', secid],
|
queryKey: ['stockDividends', secid],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await getShareDividends(secid);
|
const res = await getShareDividends(secid)
|
||||||
return res.data;
|
return res.data
|
||||||
},
|
},
|
||||||
staleTime: 86400_000,
|
staleTime: 86400_000,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { usePositionMutations } from '@/entities/portfolio';
|
import { usePositionMutations } from '@/entities/portfolio'
|
||||||
|
|
||||||
export function useAddPosition(portfolioId: number) {
|
export function useAddPosition(portfolioId: number) {
|
||||||
const { add } = usePositionMutations(portfolioId);
|
const { add } = usePositionMutations(portfolioId)
|
||||||
return add;
|
return add
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { AddPositionForm } from './ui/AddPositionForm';
|
export { AddPositionForm } from './ui/AddPositionForm'
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react'
|
||||||
|
|
||||||
export function useAddPositionForm() {
|
export function useAddPositionForm() {
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
const [newSecid, setNewSecid] = useState('');
|
const [newSecid, setNewSecid] = useState('')
|
||||||
const [newQty, setNewQty] = useState('1');
|
const [newQty, setNewQty] = useState('1')
|
||||||
const [newPrice, setNewPrice] = useState('');
|
const [newPrice, setNewPrice] = useState('')
|
||||||
const [newDate, setNewDate] = useState(new Date().toISOString().split('T')[0]);
|
const [newDate, setNewDate] = useState(new Date().toISOString().split('T')[0])
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
setNewSecid('');
|
setNewSecid('')
|
||||||
setNewQty('1');
|
setNewQty('1')
|
||||||
setNewPrice('');
|
setNewPrice('')
|
||||||
setNewDate(new Date().toISOString().split('T')[0]);
|
setNewDate(new Date().toISOString().split('T')[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -26,5 +26,5 @@ export function useAddPositionForm() {
|
|||||||
newDate,
|
newDate,
|
||||||
setNewDate,
|
setNewDate,
|
||||||
reset,
|
reset,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,19 @@
|
|||||||
import { useAddPosition } from '../api/useAddPosition';
|
import { useAddPosition } from '../api/useAddPosition'
|
||||||
import { useAddPositionForm } from '../model/useAddPositionForm';
|
import { useAddPositionForm } from '../model/useAddPositionForm'
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
border: '1px solid #e0e0e0',
|
border: '1px solid #e0e0e0',
|
||||||
borderRadius: 'var(--border-radius)',
|
borderRadius: 'var(--border-radius)',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
};
|
}
|
||||||
|
|
||||||
export function AddPositionForm({ portfolioId }: { portfolioId: number }) {
|
export function AddPositionForm({ portfolioId }: { portfolioId: number }) {
|
||||||
const addPosition = useAddPosition(portfolioId);
|
const addPosition = useAddPosition(portfolioId)
|
||||||
const form = useAddPositionForm();
|
const form = useAddPositionForm()
|
||||||
|
|
||||||
function handleAddPosition() {
|
function handleAddPosition() {
|
||||||
if (!form.newSecid.trim() || !parseInt(form.newQty, 10)) return;
|
if (!form.newSecid.trim() || !parseInt(form.newQty, 10)) return
|
||||||
addPosition.mutate(
|
addPosition.mutate(
|
||||||
{
|
{
|
||||||
secid: form.newSecid.trim().toUpperCase(),
|
secid: form.newSecid.trim().toUpperCase(),
|
||||||
@ -23,11 +23,11 @@ export function AddPositionForm({ portfolioId }: { portfolioId: number }) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
form.setShowAddForm(false);
|
form.setShowAddForm(false)
|
||||||
form.reset();
|
form.reset()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -107,5 +107,5 @@ export function AddPositionForm({ portfolioId }: { portfolioId: number }) {
|
|||||||
Добавить
|
Добавить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,40 +1,40 @@
|
|||||||
import { request } from '@/shared/api/client';
|
import { request } from '@/shared/api/kyClient'
|
||||||
import type { ScreenerResult } from '@/shared/api/responses';
|
import type { ScreenerResult } from '@/shared/api/responses'
|
||||||
|
|
||||||
export interface ScreenerQuery {
|
export interface ScreenerQuery {
|
||||||
type: 'share' | 'bond';
|
type: 'share' | 'bond'
|
||||||
priceMin?: number;
|
priceMin?: number
|
||||||
priceMax?: number;
|
priceMax?: number
|
||||||
volumeMin?: number;
|
volumeMin?: number
|
||||||
listLevel?: number;
|
listLevel?: number
|
||||||
changePercentMin?: number;
|
changePercentMin?: number
|
||||||
changePercentMax?: number;
|
changePercentMax?: number
|
||||||
capitalizationMin?: number;
|
capitalizationMin?: number
|
||||||
yieldMin?: number;
|
yieldMin?: number
|
||||||
yieldMax?: number;
|
yieldMax?: number
|
||||||
durationMin?: number;
|
durationMin?: number
|
||||||
durationMax?: number;
|
durationMax?: number
|
||||||
couponMin?: number;
|
couponMin?: number
|
||||||
couponMax?: number;
|
couponMax?: number
|
||||||
couponPercentMin?: number;
|
couponPercentMin?: number
|
||||||
couponPercentMax?: number;
|
couponPercentMax?: number
|
||||||
maturityBefore?: string;
|
maturityBefore?: string
|
||||||
maturityAfter?: string;
|
maturityAfter?: string
|
||||||
bondType?: string;
|
bondType?: string
|
||||||
sortBy?: string;
|
sortBy?: string
|
||||||
sortOrder?: 'asc' | 'desc';
|
sortOrder?: 'asc' | 'desc'
|
||||||
page?: number;
|
page?: number
|
||||||
pageSize?: number;
|
pageSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getScreenerResults(
|
export function getScreenerResults(
|
||||||
params: ScreenerQuery,
|
params: ScreenerQuery,
|
||||||
): Promise<{ data: ScreenerResult; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
): Promise<{ data: ScreenerResult; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||||
const query: Record<string, string> = {};
|
const query: Record<string, string> = {}
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== null) {
|
if (value !== undefined && value !== null) {
|
||||||
query[key] = String(value);
|
query[key] = String(value)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
return request<ScreenerResult>('/api/v1/securities/screener', query);
|
return request<ScreenerResult>('/api/v1/securities/screener', query)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
export { useScreener } from './model/useScreener';
|
export type { ScreenerQuery } from './api/screenerApi'
|
||||||
export { FilterPanel, FilterPanelShare, FilterPanelBond, ScreenerTable } from './ui';
|
export { useScreener } from './model/useScreener'
|
||||||
export type { ScreenerQuery } from './api/screenerApi';
|
export { FilterPanel, FilterPanelBond, FilterPanelShare, ScreenerTable } from './ui'
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { useScreener } from './useScreener';
|
export { useScreener } from './useScreener'
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useSearchParamsCompat } from '@/shared/lib/router/useSearchParams'
|
||||||
import { getScreenerResults } from '../api/screenerApi';
|
import type { ScreenerQuery } from '../api/screenerApi'
|
||||||
import type { ScreenerQuery } from '../api/screenerApi';
|
import { getScreenerResults } from '../api/screenerApi'
|
||||||
|
|
||||||
export function useScreener() {
|
export function useScreener() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParamsCompat()
|
||||||
|
|
||||||
const params: ScreenerQuery = {
|
const params: ScreenerQuery = {
|
||||||
type: (searchParams.get('type') as 'share' | 'bond') || 'share',
|
type: (searchParams.get('type') as 'share' | 'bond') || 'share',
|
||||||
@ -44,9 +44,9 @@ export function useScreener() {
|
|||||||
sortOrder: (searchParams.get('sortOrder') as 'asc' | 'desc') || undefined,
|
sortOrder: (searchParams.get('sortOrder') as 'asc' | 'desc') || undefined,
|
||||||
page: searchParams.get('page') ? Number(searchParams.get('page')) : undefined,
|
page: searchParams.get('page') ? Number(searchParams.get('page')) : undefined,
|
||||||
pageSize: searchParams.get('pageSize') ? Number(searchParams.get('pageSize')) : undefined,
|
pageSize: searchParams.get('pageSize') ? Number(searchParams.get('pageSize')) : undefined,
|
||||||
};
|
}
|
||||||
|
|
||||||
const queryKey = ['screener', params];
|
const queryKey = ['screener', params]
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
@ -55,62 +55,62 @@ export function useScreener() {
|
|||||||
retry: 2,
|
retry: 2,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData) => previousData,
|
||||||
});
|
})
|
||||||
|
|
||||||
function setParam(key: string, value: string | undefined) {
|
function setParam(key: string, value: string | undefined) {
|
||||||
setSearchParams((prev) => {
|
setSearchParams((prev) => {
|
||||||
const next = new URLSearchParams(prev);
|
const next = new URLSearchParams(prev)
|
||||||
if (value === undefined || value === '') {
|
if (value === undefined || value === '') {
|
||||||
next.delete(key);
|
next.delete(key)
|
||||||
} else {
|
} else {
|
||||||
next.set(key, value);
|
next.set(key, value)
|
||||||
}
|
}
|
||||||
next.set('page', '1');
|
next.set('page', '1')
|
||||||
return next;
|
return next
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFilters(filters: Partial<ScreenerQuery>) {
|
function setFilters(filters: Partial<ScreenerQuery>) {
|
||||||
setSearchParams((prev) => {
|
setSearchParams((prev) => {
|
||||||
const next = new URLSearchParams(prev);
|
const next = new URLSearchParams(prev)
|
||||||
Object.entries(filters).forEach(([key, value]) => {
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
if (value === undefined || value === null || value === '') {
|
if (value === undefined || value === null || value === '') {
|
||||||
next.delete(key);
|
next.delete(key)
|
||||||
} else {
|
} else {
|
||||||
next.set(key, String(value));
|
next.set(key, String(value))
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
next.set('page', '1');
|
next.set('page', '1')
|
||||||
return next;
|
return next
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPage(page: number) {
|
function setPage(page: number) {
|
||||||
setSearchParams((prev) => {
|
setSearchParams((prev) => {
|
||||||
const next = new URLSearchParams(prev);
|
const next = new URLSearchParams(prev)
|
||||||
next.set('page', String(page));
|
next.set('page', String(page))
|
||||||
return next;
|
return next
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSort(sortBy: string) {
|
function setSort(sortBy: string) {
|
||||||
setSearchParams((prev) => {
|
setSearchParams((prev) => {
|
||||||
const next = new URLSearchParams(prev);
|
const next = new URLSearchParams(prev)
|
||||||
const current = next.get('sortBy');
|
const current = next.get('sortBy')
|
||||||
const currentOrder = next.get('sortOrder') || 'asc';
|
const currentOrder = next.get('sortOrder') || 'asc'
|
||||||
if (current === sortBy) {
|
if (current === sortBy) {
|
||||||
next.set('sortOrder', currentOrder === 'asc' ? 'desc' : 'asc');
|
next.set('sortOrder', currentOrder === 'asc' ? 'desc' : 'asc')
|
||||||
} else {
|
} else {
|
||||||
next.set('sortBy', sortBy);
|
next.set('sortBy', sortBy)
|
||||||
next.set('sortOrder', 'asc');
|
next.set('sortOrder', 'asc')
|
||||||
}
|
}
|
||||||
next.set('page', '1');
|
next.set('page', '1')
|
||||||
return next;
|
return next
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
setSearchParams(new URLSearchParams({ type: params.type }));
|
setSearchParams(new URLSearchParams({ type: params.type }))
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -123,5 +123,5 @@ export function useScreener() {
|
|||||||
setSort,
|
setSort,
|
||||||
setParam,
|
setParam,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,36 +1,36 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react'
|
||||||
import type { ScreenerQuery } from '../api/screenerApi';
|
import type { ScreenerQuery } from '../api/screenerApi'
|
||||||
import { FilterPanelShare } from './FilterPanelShare';
|
import { FilterPanelBond } from './FilterPanelBond'
|
||||||
import { FilterPanelBond } from './FilterPanelBond';
|
import { FilterPanelShare } from './FilterPanelShare'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: ScreenerQuery;
|
params: ScreenerQuery
|
||||||
onApply: (filters: Partial<ScreenerQuery>) => void;
|
onApply: (filters: Partial<ScreenerQuery>) => void
|
||||||
onReset: () => void;
|
onReset: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterPanel({ params, onApply, onReset }: Props) {
|
export function FilterPanel({ params, onApply, onReset }: Props) {
|
||||||
const [type, setType] = useState<'share' | 'bond'>(params.type);
|
const [type, setType] = useState<'share' | 'bond'>(params.type)
|
||||||
const [local, setLocal] = useState<Record<string, string>>({});
|
const [local, setLocal] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
function handleApply() {
|
function handleApply() {
|
||||||
const filters: Partial<ScreenerQuery> = { type };
|
const filters: Partial<ScreenerQuery> = { type }
|
||||||
Object.entries(local).forEach(([key, value]) => {
|
Object.entries(local).forEach(([key, value]) => {
|
||||||
if (value !== '') {
|
if (value !== '') {
|
||||||
const num = Number(value);
|
const num = Number(value)
|
||||||
filters[key as keyof ScreenerQuery] = isNaN(num) ? (value as any) : (num as any);
|
filters[key as keyof ScreenerQuery] = Number.isNaN(num) ? (value as any) : (num as any)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
onApply(filters);
|
onApply(filters)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
setLocal({});
|
setLocal({})
|
||||||
onReset();
|
onReset()
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateField(key: string, value: string) {
|
function updateField(key: string, value: string) {
|
||||||
setLocal((prev) => ({ ...prev, [key]: value }));
|
setLocal((prev) => ({ ...prev, [key]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -104,5 +104,5 @@ export function FilterPanel({ params, onApply, onReset }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
params: Record<string, any>;
|
params: Record<string, any>
|
||||||
local: Record<string, string>;
|
local: Record<string, string>
|
||||||
updateField: (key: string, value: string) => void;
|
updateField: (key: string, value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterPanelBond({ local, updateField }: Props) {
|
export function FilterPanelBond({ local, updateField }: Props) {
|
||||||
@ -16,7 +16,7 @@ export function FilterPanelBond({ local, updateField }: Props) {
|
|||||||
{ key: 'couponMax', label: 'Купон (₽) до' },
|
{ key: 'couponMax', label: 'Купон (₽) до' },
|
||||||
{ key: 'couponPercentMin', label: 'Купон % от' },
|
{ key: 'couponPercentMin', label: 'Купон % от' },
|
||||||
{ key: 'couponPercentMax', label: 'Купон % до' },
|
{ key: 'couponPercentMax', label: 'Купон % до' },
|
||||||
];
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
@ -49,5 +49,5 @@ export function FilterPanelBond({ local, updateField }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
params: Record<string, any>;
|
params: Record<string, any>
|
||||||
local: Record<string, string>;
|
local: Record<string, string>
|
||||||
updateField: (key: string, value: string) => void;
|
updateField: (key: string, value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterPanelShare({ local, updateField }: Props) {
|
export function FilterPanelShare({ local, updateField }: Props) {
|
||||||
@ -12,7 +12,7 @@ export function FilterPanelShare({ local, updateField }: Props) {
|
|||||||
{ key: 'changePercentMax', label: 'Изм. % до' },
|
{ key: 'changePercentMax', label: 'Изм. % до' },
|
||||||
{ key: 'volumeMin', label: 'Объём от' },
|
{ key: 'volumeMin', label: 'Объём от' },
|
||||||
{ key: 'capitalizationMin', label: 'Капитализация от' },
|
{ key: 'capitalizationMin', label: 'Капитализация от' },
|
||||||
];
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
@ -45,5 +45,5 @@ export function FilterPanelShare({ local, updateField }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,31 +1,31 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from '@tanstack/react-router'
|
||||||
import type { ScreenerResult } from '@/shared/api/responses';
|
import type { ScreenerResult } from '@/shared/api/responses'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
result: ScreenerResult;
|
result: ScreenerResult
|
||||||
sortBy: string;
|
sortBy: string
|
||||||
sortOrder: 'asc' | 'desc';
|
sortOrder: 'asc' | 'desc'
|
||||||
onSort: (field: string) => void;
|
onSort: (field: string) => void
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNum(value: number | null | undefined, digits = 2): string {
|
function formatNum(value: number | null | undefined, digits = 2): string {
|
||||||
if (value == null) return '—';
|
if (value == null) return '—'
|
||||||
return value.toLocaleString('ru-RU', {
|
return value.toLocaleString('ru-RU', {
|
||||||
minimumFractionDigits: digits,
|
minimumFractionDigits: digits,
|
||||||
maximumFractionDigits: digits,
|
maximumFractionDigits: digits,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatChange(value: number | null | undefined): { text: string; color: string } {
|
function formatChange(value: number | null | undefined): { text: string; color: string } {
|
||||||
if (value == null) return { text: '—', color: 'inherit' };
|
if (value == null) return { text: '—', color: 'inherit' }
|
||||||
const color = value > 0 ? '#43a047' : value < 0 ? '#e53935' : 'inherit';
|
const color = value > 0 ? '#43a047' : value < 0 ? '#e53935' : 'inherit'
|
||||||
return { text: `${value > 0 ? '+' : ''}${value.toFixed(2)}%`, color };
|
return { text: `${value > 0 ? '+' : ''}${value.toFixed(2)}%`, color }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange }: Props) {
|
export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange }: Props) {
|
||||||
function SortHeader({ field, children }: { field: string; children: string }) {
|
function SortHeader({ field, children }: { field: string; children: string }) {
|
||||||
const isActive = sortBy === field;
|
const isActive = sortBy === field
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
onClick={() => onSort(field)}
|
onClick={() => onSort(field)}
|
||||||
@ -42,10 +42,10 @@ export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange
|
|||||||
>
|
>
|
||||||
{children} {isActive ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
{children} {isActive ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||||
</th>
|
</th>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isShare = result.items[0]?.type === 'share';
|
const isShare = result.items[0]?.type === 'share'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
@ -95,8 +95,8 @@ export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{result.items.map((item) => {
|
{result.items.map((item) => {
|
||||||
const change = formatChange(item.changePercent);
|
const change = formatChange(item.changePercent)
|
||||||
const link = isShare ? `/stocks/${item.secid}` : `/bonds/${item.secid}`;
|
const link = isShare ? `/stocks/${item.secid}` : `/bonds/${item.secid}`
|
||||||
return (
|
return (
|
||||||
<tr key={item.secid} style={{ borderBottom: '1px solid #f0f0f0' }}>
|
<tr key={item.secid} style={{ borderBottom: '1px solid #f0f0f0' }}>
|
||||||
<td style={{ padding: '8px 12px', fontWeight: 600, fontFamily: 'monospace' }}>
|
<td style={{ padding: '8px 12px', fontWeight: 600, fontFamily: 'monospace' }}>
|
||||||
@ -139,7 +139,7 @@ export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -167,5 +167,5 @@ export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
export { FilterPanel } from './FilterPanel';
|
export { FilterPanel } from './FilterPanel'
|
||||||
export { FilterPanelShare } from './FilterPanelShare';
|
export { FilterPanelBond } from './FilterPanelBond'
|
||||||
export { FilterPanelBond } from './FilterPanelBond';
|
export { FilterPanelShare } from './FilterPanelShare'
|
||||||
export { ScreenerTable } from './ScreenerTable';
|
export { ScreenerTable } from './ScreenerTable'
|
||||||
|
|||||||
@ -1,13 +1,23 @@
|
|||||||
import React from 'react';
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client'
|
||||||
import { AppProviders } from './app/providers/AppProviders';
|
import App from './app/App'
|
||||||
import App from './app/App';
|
import { AppProviders } from './app/providers/AppProviders'
|
||||||
import './styles.css';
|
import './styles.css'
|
||||||
|
import { env } from './shared/config/env'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
async function startApp() {
|
||||||
<React.StrictMode>
|
if (env.VITE_API_MOCK) {
|
||||||
<AppProviders>
|
const { worker } = await import('./shared/lib/test/browser')
|
||||||
<App />
|
await worker.start({ onUnhandledRequest: 'bypass' })
|
||||||
</AppProviders>
|
}
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<AppProviders>
|
||||||
|
<App />
|
||||||
|
</AppProviders>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
startApp()
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { BondPage } from './ui/BondPage';
|
export { BondPage } from './ui/BondPage'
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from '@tanstack/react-router'
|
||||||
import { useBond, useBondCandles } from '@/entities/bond';
|
import { useBond, useBondCandles } from '@/entities/bond'
|
||||||
import { BondDetails } from '@/widgets/bond-details';
|
import { BondDetails } from '@/widgets/bond-details'
|
||||||
import { PriceChart } from '@/widgets/price-chart';
|
import { PriceChart } from '@/widgets/price-chart'
|
||||||
|
|
||||||
export function BondPage() {
|
export function BondPage() {
|
||||||
const { secid } = useParams<{ secid: string }>();
|
const { secid } = useParams<{ secid: string }>()
|
||||||
const { data: bond, isLoading, error } = useBond(secid!);
|
const { data: bond, isLoading, error } = useBond(secid!)
|
||||||
const till = new Date().toISOString().split('T')[0];
|
const till = new Date().toISOString().split('T')[0]
|
||||||
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||||
const { data: candles } = useBondCandles(secid!, '24h', from, till);
|
const { data: candles } = useBondCandles(secid!, '24h', from, till)
|
||||||
|
|
||||||
if (isLoading) return <div>Загрузка...</div>;
|
if (isLoading) return <div>Загрузка...</div>
|
||||||
if (error || !bond) return <div>Инструмент не найден</div>;
|
if (error || !bond) return <div>Инструмент не найден</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
@ -29,5 +29,5 @@ export function BondPage() {
|
|||||||
<PriceChart data={candles ?? []} />
|
<PriceChart data={candles ?? []} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { BrokerAccountOverviewPage } from './ui/BrokerAccountOverviewPage';
|
export { BrokerAccountOverviewPage } from './ui/BrokerAccountOverviewPage'
|
||||||
|
|||||||
@ -1,24 +1,24 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Text } from '@moex-vibe/design-system'
|
||||||
import { Box } from '@mui/material';
|
import { Box } from '@mui/material'
|
||||||
import { Text } from '@moex-vibe/design-system';
|
import { Link } from '@tanstack/react-router'
|
||||||
import { useBrokerOperations } from '@/entities/broker-operation';
|
import { useBrokerOperations } from '@/entities/broker-operation'
|
||||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'
|
||||||
import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart';
|
import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'
|
||||||
import { BrokerEventsOverview } from '@/widgets/broker-events-overview';
|
import { BrokerEventsOverview } from '@/widgets/broker-events-overview'
|
||||||
import { BrokerOperationsTable } from '@/widgets/broker-operations-table';
|
import { BrokerOperationsTable } from '@/widgets/broker-operations-table'
|
||||||
import { BrokerSummary, BrokerAssetCards, BrokerOverviewSkeleton } from '@/widgets/broker-overview';
|
import { BrokerAssetCards, BrokerOverviewSkeleton, BrokerSummary } from '@/widgets/broker-overview'
|
||||||
|
|
||||||
export function BrokerAccountOverviewPage() {
|
export function BrokerAccountOverviewPage() {
|
||||||
const { accountId, portfolio } = useBrokerAccountContext();
|
const { accountId, portfolio } = useBrokerAccountContext()
|
||||||
const operations = useBrokerOperations(accountId, { limit: 5 });
|
const operations = useBrokerOperations(accountId, { limit: 5 })
|
||||||
|
|
||||||
if (portfolio.isLoading) return <BrokerOverviewSkeleton />;
|
if (portfolio.isLoading) return <BrokerOverviewSkeleton />
|
||||||
if (portfolio.error || !portfolio.data) {
|
if (portfolio.error || !portfolio.data) {
|
||||||
return (
|
return (
|
||||||
<Text component="p" role="alert" tone="negative">
|
<Text component="p" role="alert" tone="negative">
|
||||||
Не удалось загрузить сводку счёта
|
Не удалось загрузить сводку счёта
|
||||||
</Text>
|
</Text>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -44,5 +44,5 @@ export function BrokerAccountOverviewPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { BrokerAccountsPage } from './ui/BrokerAccountsPage';
|
export { BrokerAccountsPage } from './ui/BrokerAccountsPage'
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { Box } from '@mui/material';
|
import { EmptyState, Heading, Text } from '@moex-vibe/design-system'
|
||||||
import { EmptyState, Heading, Text } from '@moex-vibe/design-system';
|
import { Box } from '@mui/material'
|
||||||
import {
|
import {
|
||||||
aggregateBrokerAccounts,
|
aggregateBrokerAccounts,
|
||||||
useBrokerAccounts,
|
|
||||||
useBrokerAccountPortfolios,
|
useBrokerAccountPortfolios,
|
||||||
} from '@/entities/broker-account';
|
useBrokerAccounts,
|
||||||
import { BrokerAccountCard } from '@/widgets/broker-account-card';
|
} from '@/entities/broker-account'
|
||||||
import { BrokerAccountsSummary } from '@/widgets/broker-accounts-summary';
|
import { BrokerAccountCard } from '@/widgets/broker-account-card'
|
||||||
|
import { BrokerAccountsSummary } from '@/widgets/broker-accounts-summary'
|
||||||
|
|
||||||
function BrokerAccountsPageSkeleton() {
|
function BrokerAccountsPageSkeleton() {
|
||||||
return (
|
return (
|
||||||
@ -42,20 +42,20 @@ function BrokerAccountsPageSkeleton() {
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BrokerAccountsPage() {
|
export function BrokerAccountsPage() {
|
||||||
const { data: accounts, isLoading, error } = useBrokerAccounts();
|
const { data: accounts, isLoading, error } = useBrokerAccounts()
|
||||||
const safeAccounts = accounts ?? [];
|
const safeAccounts = accounts ?? []
|
||||||
const accountQueries = useBrokerAccountPortfolios(safeAccounts);
|
const accountQueries = useBrokerAccountPortfolios(safeAccounts)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <BrokerAccountsPageSkeleton />;
|
return <BrokerAccountsPageSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <Text tone="negative">Не удалось загрузить счета</Text>;
|
return <Text tone="negative">Не удалось загрузить счета</Text>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (safeAccounts.length === 0) {
|
if (safeAccounts.length === 0) {
|
||||||
@ -72,17 +72,17 @@ export function BrokerAccountsPage() {
|
|||||||
description="После подключения T-Bank здесь появятся брокерские счета и ИИС со сводкой по капиталу."
|
description="После подключения T-Bank здесь появятся брокерские счета и ИИС со сводкой по капиталу."
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const successfulPortfolios = accountQueries
|
const successfulPortfolios = accountQueries
|
||||||
.map(({ query }) => query.data)
|
.map(({ query }) => query.data)
|
||||||
.filter((portfolio): portfolio is NonNullable<typeof portfolio> => Boolean(portfolio));
|
.filter((portfolio): portfolio is NonNullable<typeof portfolio> => Boolean(portfolio))
|
||||||
const loadingCount = accountQueries.filter(
|
const loadingCount = accountQueries.filter(
|
||||||
({ query }) => (query.isLoading || query.isPending || query.isFetching) && !query.data,
|
({ query }) => (query.isLoading || query.isPending || query.isFetching) && !query.data,
|
||||||
).length;
|
).length
|
||||||
const availableCount = successfulPortfolios.length;
|
const availableCount = successfulPortfolios.length
|
||||||
const aggregate = aggregateBrokerAccounts(successfulPortfolios);
|
const aggregate = aggregateBrokerAccounts(successfulPortfolios)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'grid', gap: 3 }}>
|
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||||
@ -112,11 +112,11 @@ export function BrokerAccountsPage() {
|
|||||||
isLoading={(query.isLoading || query.isPending || query.isFetching) && !query.data}
|
isLoading={(query.isLoading || query.isPending || query.isFetching) && !query.data}
|
||||||
error={(query.error as Error | null) ?? null}
|
error={(query.error as Error | null) ?? null}
|
||||||
onRetry={() => {
|
onRetry={() => {
|
||||||
void query.refetch();
|
void query.refetch()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { BrokerEventsPage } from './ui/BrokerEventsPage';
|
export { BrokerEventsPage } from './ui/BrokerEventsPage'
|
||||||
|
|||||||
@ -1,23 +1,34 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event'
|
||||||
import React, { type ReactNode } from 'react';
|
import type { ReactNode } from 'react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { BrokerEventsPage } from './BrokerEventsPage';
|
import { BrokerEventsPage } from './BrokerEventsPage'
|
||||||
|
|
||||||
|
const mockSetSearchParams = vi.fn()
|
||||||
|
const mockSearchParams = new URLSearchParams()
|
||||||
|
|
||||||
vi.mock('@/entities/broker-event', () => ({
|
vi.mock('@/entities/broker-event', () => ({
|
||||||
useBrokerEvents: vi.fn(),
|
useBrokerEvents: vi.fn(),
|
||||||
}));
|
}))
|
||||||
|
|
||||||
vi.mock('@/widgets/broker-account-layout', () => ({
|
vi.mock('@/widgets/broker-account-layout', () => ({
|
||||||
useBrokerAccountContext: () => ({ accountId: 'acc-1', portfolio: null }),
|
useBrokerAccountContext: () => ({ accountId: 'acc-1', portfolio: null }),
|
||||||
}));
|
}))
|
||||||
|
|
||||||
const mockSetSearchParams = vi.fn();
|
vi.mock('@/shared/lib/router/useSearchParams', () => ({
|
||||||
let currentSearchParams = new URLSearchParams();
|
useSearchParamsCompat: () => [mockSearchParams, mockSetSearchParams],
|
||||||
vi.mock('react-router-dom', () => ({
|
}))
|
||||||
useSearchParams: () => [currentSearchParams, mockSetSearchParams],
|
|
||||||
}));
|
vi.mock('@tanstack/react-router', async () => {
|
||||||
|
const actual = await vi.importActual('@tanstack/react-router')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate: () => vi.fn(),
|
||||||
|
Link: actual.Link,
|
||||||
|
Outlet: actual.Outlet,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('@moex-vibe/design-system', () => ({
|
vi.mock('@moex-vibe/design-system', () => ({
|
||||||
Button: ({ children, onClick, disabled }: any) => (
|
Button: ({ children, onClick, disabled }: any) => (
|
||||||
@ -42,16 +53,16 @@ vi.mock('@moex-vibe/design-system', () => ({
|
|||||||
type={props.type || 'text'}
|
type={props.type || 'text'}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}));
|
}))
|
||||||
|
|
||||||
import { useBrokerEvents } from '@/entities/broker-event';
|
import { useBrokerEvents } from '@/entities/broker-event'
|
||||||
|
|
||||||
function createWrapper() {
|
function createWrapper() {
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
|
||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockData = {
|
const mockData = {
|
||||||
@ -124,13 +135,15 @@ const mockData = {
|
|||||||
estimateMode: null,
|
estimateMode: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
}
|
||||||
|
|
||||||
describe('BrokerEventsPage', () => {
|
describe('BrokerEventsPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks()
|
||||||
currentSearchParams = new URLSearchParams();
|
mockSearchParams.delete('from')
|
||||||
});
|
mockSearchParams.delete('to')
|
||||||
|
mockSearchParams.delete('types')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders loading state', () => {
|
it('renders loading state', () => {
|
||||||
vi.mocked(useBrokerEvents).mockReturnValue({
|
vi.mocked(useBrokerEvents).mockReturnValue({
|
||||||
@ -158,11 +171,11 @@ describe('BrokerEventsPage', () => {
|
|||||||
promise: new Promise<never>(() => {}),
|
promise: new Promise<never>(() => {}),
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
fetchStatus: 'fetching',
|
fetchStatus: 'fetching',
|
||||||
} as unknown as ReturnType<typeof useBrokerEvents>);
|
} as unknown as ReturnType<typeof useBrokerEvents>)
|
||||||
|
|
||||||
render(<BrokerEventsPage />, { wrapper: createWrapper() });
|
render(<BrokerEventsPage />, { wrapper: createWrapper() })
|
||||||
expect(screen.getByText('Загрузка событий…')).toBeInTheDocument();
|
expect(screen.getByText('Загрузка событий…')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders error state', () => {
|
it('renders error state', () => {
|
||||||
vi.mocked(useBrokerEvents).mockReturnValue({
|
vi.mocked(useBrokerEvents).mockReturnValue({
|
||||||
@ -190,11 +203,11 @@ describe('BrokerEventsPage', () => {
|
|||||||
promise: new Promise<never>(() => {}),
|
promise: new Promise<never>(() => {}),
|
||||||
status: 'error',
|
status: 'error',
|
||||||
fetchStatus: 'idle',
|
fetchStatus: 'idle',
|
||||||
} as unknown as ReturnType<typeof useBrokerEvents>);
|
} as unknown as ReturnType<typeof useBrokerEvents>)
|
||||||
|
|
||||||
render(<BrokerEventsPage />, { wrapper: createWrapper() });
|
render(<BrokerEventsPage />, { wrapper: createWrapper() })
|
||||||
expect(screen.getByText('Не удалось загрузить календарь событий')).toBeInTheDocument();
|
expect(screen.getByText('Не удалось загрузить календарь событий')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders empty state', () => {
|
it('renders empty state', () => {
|
||||||
vi.mocked(useBrokerEvents).mockReturnValue({
|
vi.mocked(useBrokerEvents).mockReturnValue({
|
||||||
@ -252,11 +265,11 @@ describe('BrokerEventsPage', () => {
|
|||||||
}),
|
}),
|
||||||
status: 'success',
|
status: 'success',
|
||||||
fetchStatus: 'idle',
|
fetchStatus: 'idle',
|
||||||
} as unknown as ReturnType<typeof useBrokerEvents>);
|
} as unknown as ReturnType<typeof useBrokerEvents>)
|
||||||
|
|
||||||
render(<BrokerEventsPage />, { wrapper: createWrapper() });
|
render(<BrokerEventsPage />, { wrapper: createWrapper() })
|
||||||
expect(screen.getByText('В выбранном диапазоне событий нет')).toBeInTheDocument();
|
expect(screen.getByText('В выбранном диапазоне событий нет')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders date range inputs', () => {
|
it('renders date range inputs', () => {
|
||||||
vi.mocked(useBrokerEvents).mockReturnValue({
|
vi.mocked(useBrokerEvents).mockReturnValue({
|
||||||
@ -284,12 +297,12 @@ describe('BrokerEventsPage', () => {
|
|||||||
promise: Promise.resolve(mockData),
|
promise: Promise.resolve(mockData),
|
||||||
status: 'success',
|
status: 'success',
|
||||||
fetchStatus: 'idle',
|
fetchStatus: 'idle',
|
||||||
} as unknown as ReturnType<typeof useBrokerEvents>);
|
} as unknown as ReturnType<typeof useBrokerEvents>)
|
||||||
|
|
||||||
render(<BrokerEventsPage />, { wrapper: createWrapper() });
|
render(<BrokerEventsPage />, { wrapper: createWrapper() })
|
||||||
expect(screen.getByLabelText('С')).toBeInTheDocument();
|
expect(screen.getByLabelText('С')).toBeInTheDocument()
|
||||||
expect(screen.getByLabelText('По')).toBeInTheDocument();
|
expect(screen.getByLabelText('По')).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|
||||||
it('renders events heading, summary and table', () => {
|
it('renders events heading, summary and table', () => {
|
||||||
vi.mocked(useBrokerEvents).mockReturnValue({
|
vi.mocked(useBrokerEvents).mockReturnValue({
|
||||||
@ -317,33 +330,31 @@ describe('BrokerEventsPage', () => {
|
|||||||
promise: Promise.resolve(mockData),
|
promise: Promise.resolve(mockData),
|
||||||
status: 'success',
|
status: 'success',
|
||||||
fetchStatus: 'idle',
|
fetchStatus: 'idle',
|
||||||
} as unknown as ReturnType<typeof useBrokerEvents>);
|
} as unknown as ReturnType<typeof useBrokerEvents>)
|
||||||
|
|
||||||
render(<BrokerEventsPage />, { wrapper: createWrapper() });
|
render(<BrokerEventsPage />, { wrapper: createWrapper() })
|
||||||
|
|
||||||
expect(screen.getByText('События')).toBeInTheDocument();
|
expect(screen.getByText('События')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Событий')).toBeInTheDocument();
|
expect(screen.getByText('Событий')).toBeInTheDocument()
|
||||||
expect(screen.getByText('3')).toBeInTheDocument();
|
expect(screen.getByText('3')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Ближайшее')).toBeInTheDocument();
|
expect(screen.getByText('Ближайшее')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Прогноз выплат')).toBeInTheDocument();
|
expect(screen.getByText('Прогноз выплат')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Дивиденд')).toBeInTheDocument();
|
expect(screen.getByText('Дивиденд')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Купон')).toBeInTheDocument();
|
expect(screen.getByText('Купон')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Погашение')).toBeInTheDocument();
|
expect(screen.getByText('Погашение')).toBeInTheDocument()
|
||||||
expect(screen.getByText('SBER')).toBeInTheDocument();
|
expect(screen.getByText('SBER')).toBeInTheDocument()
|
||||||
expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument();
|
expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument()
|
||||||
expect(screen.getByText('VTBR')).toBeInTheDocument();
|
expect(screen.getByText('VTBR')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Прогноз выплат')).toBeInTheDocument();
|
expect(screen.getByText('Прогноз выплат')).toBeInTheDocument()
|
||||||
expect(screen.getAllByText('Поступило').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Поступило').length).toBeGreaterThan(0)
|
||||||
expect(screen.getByText('Факт')).toBeInTheDocument();
|
expect(screen.getByText('Факт')).toBeInTheDocument()
|
||||||
expect(screen.getAllByText('Прогноз').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Прогноз').length).toBeGreaterThan(0)
|
||||||
});
|
})
|
||||||
|
|
||||||
it('keeps date and type changes as draft until applying filters', async () => {
|
it('keeps date and type changes as draft until applying filters', async () => {
|
||||||
currentSearchParams = new URLSearchParams({
|
mockSearchParams.set('from', '2026-06-15')
|
||||||
from: '2026-06-15',
|
mockSearchParams.set('to', '2026-06-29')
|
||||||
to: '2026-06-29',
|
mockSearchParams.set('types', 'dividend,coupon')
|
||||||
types: 'dividend,coupon',
|
|
||||||
});
|
|
||||||
vi.mocked(useBrokerEvents).mockReturnValue({
|
vi.mocked(useBrokerEvents).mockReturnValue({
|
||||||
data: mockData,
|
data: mockData,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@ -369,21 +380,21 @@ describe('BrokerEventsPage', () => {
|
|||||||
promise: Promise.resolve(mockData),
|
promise: Promise.resolve(mockData),
|
||||||
status: 'success',
|
status: 'success',
|
||||||
fetchStatus: 'idle',
|
fetchStatus: 'idle',
|
||||||
} as unknown as ReturnType<typeof useBrokerEvents>);
|
} as unknown as ReturnType<typeof useBrokerEvents>)
|
||||||
|
|
||||||
render(<BrokerEventsPage />, { wrapper: createWrapper() });
|
render(<BrokerEventsPage />, { wrapper: createWrapper() })
|
||||||
|
|
||||||
await userEvent.clear(screen.getByLabelText('С'));
|
await userEvent.clear(screen.getByLabelText('С'))
|
||||||
await userEvent.type(screen.getByLabelText('С'), '2026-06-10');
|
await userEvent.type(screen.getByLabelText('С'), '2026-06-10')
|
||||||
await userEvent.click(screen.getByLabelText('Купоны'));
|
await userEvent.click(screen.getByLabelText('Купоны'))
|
||||||
|
|
||||||
expect(mockSetSearchParams).not.toHaveBeenCalled();
|
expect(mockSetSearchParams).not.toHaveBeenCalled()
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Показать' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Показать' }))
|
||||||
|
|
||||||
const applied = mockSetSearchParams.mock.calls[0][0] as URLSearchParams;
|
const applied = mockSetSearchParams.mock.calls[0][0] as URLSearchParams
|
||||||
expect(applied.get('from')).toBe('2026-06-10');
|
expect(applied.get('from')).toBe('2026-06-10')
|
||||||
expect(applied.get('to')).toBe('2026-06-29');
|
expect(applied.get('to')).toBe('2026-06-29')
|
||||||
expect(applied.get('types')).toBe('dividend');
|
expect(applied.get('types')).toBe('dividend')
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
@ -1,122 +1,122 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { Button, Checkbox, Chip, Heading, Text, TextField } from '@moex-vibe/design-system'
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { Box } from '@mui/material'
|
||||||
import { Box } from '@mui/material';
|
import dayjs from 'dayjs'
|
||||||
import { Button, Checkbox, Chip, Heading, Text, TextField } from '@moex-vibe/design-system';
|
import { useEffect, useState } from 'react'
|
||||||
import dayjs from 'dayjs';
|
import { useBrokerEvents } from '@/entities/broker-event'
|
||||||
import { useBrokerEvents } from '@/entities/broker-event';
|
import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'
|
||||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
import { useSearchParamsCompat } from '@/shared/lib/router/useSearchParams'
|
||||||
import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters';
|
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'
|
||||||
|
|
||||||
const EVENT_TYPES = ['dividend', 'coupon', 'maturity', 'offer'] as const;
|
const EVENT_TYPES = ['dividend', 'coupon', 'maturity', 'offer'] as const
|
||||||
|
|
||||||
type EventType = (typeof EVENT_TYPES)[number];
|
type EventType = (typeof EVENT_TYPES)[number]
|
||||||
|
|
||||||
type Filters = {
|
type Filters = {
|
||||||
from: string;
|
from: string
|
||||||
to: string;
|
to: string
|
||||||
types: EventType[];
|
types: EventType[]
|
||||||
};
|
}
|
||||||
|
|
||||||
const EVENT_TYPE_OPTIONS: { value: EventType; label: string }[] = [
|
const EVENT_TYPE_OPTIONS: { value: EventType; label: string }[] = [
|
||||||
{ value: 'dividend', label: 'Дивиденды' },
|
{ value: 'dividend', label: 'Дивиденды' },
|
||||||
{ value: 'coupon', label: 'Купоны' },
|
{ value: 'coupon', label: 'Купоны' },
|
||||||
{ value: 'maturity', label: 'Погашения' },
|
{ value: 'maturity', label: 'Погашения' },
|
||||||
{ value: 'offer', label: 'Оферты' },
|
{ value: 'offer', label: 'Оферты' },
|
||||||
];
|
]
|
||||||
|
|
||||||
function eventTypeLabel(type: string): string {
|
function eventTypeLabel(type: string): string {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'dividend':
|
case 'dividend':
|
||||||
return 'Дивиденд';
|
return 'Дивиденд'
|
||||||
case 'coupon':
|
case 'coupon':
|
||||||
return 'Купон';
|
return 'Купон'
|
||||||
case 'maturity':
|
case 'maturity':
|
||||||
return 'Погашение';
|
return 'Погашение'
|
||||||
case 'offer':
|
case 'offer':
|
||||||
return 'Оферта';
|
return 'Оферта'
|
||||||
default:
|
default:
|
||||||
return type;
|
return type
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultPeriod(): { from: string; to: string } {
|
function defaultPeriod(): { from: string; to: string } {
|
||||||
const now = dayjs();
|
const now = dayjs()
|
||||||
return {
|
return {
|
||||||
from: now.subtract(7, 'day').format('YYYY-MM-DD'),
|
from: now.subtract(7, 'day').format('YYYY-MM-DD'),
|
||||||
to: now.add(7, 'day').format('YYYY-MM-DD'),
|
to: now.add(7, 'day').format('YYYY-MM-DD'),
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTypes(value: string | null): EventType[] {
|
function parseTypes(value: string | null): EventType[] {
|
||||||
if (!value) return [...EVENT_TYPES];
|
if (!value) return [...EVENT_TYPES]
|
||||||
|
|
||||||
const parsed = value
|
const parsed = value
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((type) => type.trim())
|
.map((type) => type.trim())
|
||||||
.filter((type): type is EventType => EVENT_TYPES.includes(type as EventType));
|
.filter((type): type is EventType => EVENT_TYPES.includes(type as EventType))
|
||||||
|
|
||||||
return parsed.length > 0 ? parsed : [...EVENT_TYPES];
|
return parsed.length > 0 ? parsed : [...EVENT_TYPES]
|
||||||
}
|
}
|
||||||
|
|
||||||
function filtersFromSearchParams(searchParams: URLSearchParams): Filters {
|
function filtersFromSearchParams(searchParams: URLSearchParams): Filters {
|
||||||
const def = defaultPeriod();
|
const def = defaultPeriod()
|
||||||
const from = searchParams.get('from');
|
const from = searchParams.get('from')
|
||||||
const to = searchParams.get('to');
|
const to = searchParams.get('to')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
from: from && dayjs(from).isValid() ? from : def.from,
|
from: from && dayjs(from).isValid() ? from : def.from,
|
||||||
to: to && dayjs(to).isValid() ? to : def.to,
|
to: to && dayjs(to).isValid() ? to : def.to,
|
||||||
types: parseTypes(searchParams.get('types')),
|
types: parseTypes(searchParams.get('types')),
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function filtersToSearchParams(filters: Filters): URLSearchParams {
|
function filtersToSearchParams(filters: Filters): URLSearchParams {
|
||||||
const next = new URLSearchParams();
|
const next = new URLSearchParams()
|
||||||
next.set('from', filters.from);
|
next.set('from', filters.from)
|
||||||
next.set('to', filters.to);
|
next.set('to', filters.to)
|
||||||
next.set('types', filters.types.join(','));
|
next.set('types', filters.types.join(','))
|
||||||
return next;
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceLabel(source: string): string {
|
function sourceLabel(source: string): string {
|
||||||
return source === 'actual' ? 'Факт' : 'Прогноз';
|
return source === 'actual' ? 'Факт' : 'Прогноз'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BrokerEventsPage() {
|
export function BrokerEventsPage() {
|
||||||
const { accountId } = useBrokerAccountContext();
|
const { accountId } = useBrokerAccountContext()
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParamsCompat()
|
||||||
const [appliedFilters, setAppliedFilters] = useState<Filters>(() =>
|
const [appliedFilters, setAppliedFilters] = useState<Filters>(() =>
|
||||||
filtersFromSearchParams(searchParams),
|
filtersFromSearchParams(searchParams),
|
||||||
);
|
)
|
||||||
const [draftFilters, setDraftFilters] = useState<Filters>(() =>
|
const [draftFilters, setDraftFilters] = useState<Filters>(() =>
|
||||||
filtersFromSearchParams(searchParams),
|
filtersFromSearchParams(searchParams),
|
||||||
);
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const next = filtersFromSearchParams(searchParams);
|
const next = filtersFromSearchParams(searchParams)
|
||||||
setAppliedFilters(next);
|
setAppliedFilters(next)
|
||||||
setDraftFilters(next);
|
setDraftFilters(next)
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [searchParams])
|
||||||
|
|
||||||
const from = draftFilters.from;
|
const from = draftFilters.from
|
||||||
const to = draftFilters.to;
|
const to = draftFilters.to
|
||||||
|
|
||||||
const validFrom = dayjs(from);
|
const validFrom = dayjs(from)
|
||||||
const validTo = dayjs(to);
|
const validTo = dayjs(to)
|
||||||
const dateError =
|
const dateError =
|
||||||
from && to && validFrom.isValid() && validTo.isValid() && validTo.isBefore(validFrom)
|
from && to && validFrom.isValid() && validTo.isValid() && validTo.isBefore(validFrom)
|
||||||
? '"По" не может быть раньше "С"'
|
? '"По" не может быть раньше "С"'
|
||||||
: '';
|
: ''
|
||||||
const typeError = draftFilters.types.length === 0 ? 'Выберите хотя бы один тип события' : '';
|
const typeError = draftFilters.types.length === 0 ? 'Выберите хотя бы один тип события' : ''
|
||||||
const filterError = dateError || typeError;
|
const filterError = dateError || typeError
|
||||||
|
|
||||||
const events = useBrokerEvents(filterError ? undefined : accountId, {
|
const events = useBrokerEvents(filterError ? undefined : accountId, {
|
||||||
from: appliedFilters.from,
|
from: appliedFilters.from,
|
||||||
to: appliedFilters.to,
|
to: appliedFilters.to,
|
||||||
types: appliedFilters.types.join(','),
|
types: appliedFilters.types.join(','),
|
||||||
});
|
})
|
||||||
|
|
||||||
const ev = events.data;
|
const ev = events.data
|
||||||
|
|
||||||
function toggleType(type: EventType, checked: boolean) {
|
function toggleType(type: EventType, checked: boolean) {
|
||||||
setDraftFilters((current) => ({
|
setDraftFilters((current) => ({
|
||||||
@ -124,13 +124,13 @@ export function BrokerEventsPage() {
|
|||||||
types: checked
|
types: checked
|
||||||
? [...new Set([...current.types, type])]
|
? [...new Set([...current.types, type])]
|
||||||
: current.types.filter((t) => t !== type),
|
: current.types.filter((t) => t !== type),
|
||||||
}));
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
if (filterError) return;
|
if (filterError) return
|
||||||
setAppliedFilters(draftFilters);
|
setAppliedFilters(draftFilters)
|
||||||
setSearchParams(filtersToSearchParams(draftFilters), { replace: true });
|
setSearchParams(filtersToSearchParams(draftFilters), { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -147,7 +147,7 @@ export function BrokerEventsPage() {
|
|||||||
type="date"
|
type="date"
|
||||||
value={from}
|
value={from}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setDraftFilters((current) => ({ ...current, from: e.target.value }));
|
setDraftFilters((current) => ({ ...current, from: e.target.value }))
|
||||||
}}
|
}}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
/>
|
/>
|
||||||
@ -156,7 +156,7 @@ export function BrokerEventsPage() {
|
|||||||
type="date"
|
type="date"
|
||||||
value={to}
|
value={to}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setDraftFilters((current) => ({ ...current, to: e.target.value }));
|
setDraftFilters((current) => ({ ...current, to: e.target.value }))
|
||||||
}}
|
}}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
error={!!dateError}
|
error={!!dateError}
|
||||||
@ -406,5 +406,5 @@ export function BrokerEventsPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { BrokerOperationsPage } from './ui/BrokerOperationsPage';
|
export { BrokerOperationsPage } from './ui/BrokerOperationsPage'
|
||||||
|
|||||||
@ -1,36 +1,36 @@
|
|||||||
import { useEffect } from 'react';
|
import { Heading, Text } from '@moex-vibe/design-system'
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { Box } from '@mui/material'
|
||||||
import { Box } from '@mui/material';
|
import { useEffect } from 'react'
|
||||||
import { Heading, Text } from '@moex-vibe/design-system';
|
|
||||||
import {
|
import {
|
||||||
BROKER_OPERATION_TYPE_OPTIONS,
|
BROKER_OPERATION_TYPE_OPTIONS,
|
||||||
isBrokerOperationType,
|
isBrokerOperationType,
|
||||||
useBrokerOperations,
|
useBrokerOperations,
|
||||||
} from '@/entities/broker-operation';
|
} from '@/entities/broker-operation'
|
||||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
|
import { useSearchParamsCompat } from '@/shared/lib/router/useSearchParams'
|
||||||
import { BrokerOperationsTable } from '@/widgets/broker-operations-table';
|
import { useCursorPagination } from '@/shared/lib/useCursorPagination'
|
||||||
import { useCursorPagination } from '@/shared/lib/useCursorPagination';
|
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'
|
||||||
|
import { BrokerOperationsTable } from '@/widgets/broker-operations-table'
|
||||||
|
|
||||||
export function BrokerOperationsPage() {
|
export function BrokerOperationsPage() {
|
||||||
const { accountId } = useBrokerAccountContext();
|
const { accountId } = useBrokerAccountContext()
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParamsCompat()
|
||||||
const urlType = searchParams.get('type');
|
const urlType = searchParams.get('type')
|
||||||
const selectedType = isBrokerOperationType(urlType) ? urlType : '';
|
const selectedType = isBrokerOperationType(urlType) ? urlType : ''
|
||||||
const pagination = useCursorPagination();
|
const pagination = useCursorPagination()
|
||||||
|
|
||||||
const operations = useBrokerOperations(accountId, {
|
const operations = useBrokerOperations(accountId, {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
cursor: pagination.cursor,
|
cursor: pagination.cursor,
|
||||||
operationTypes: selectedType || undefined,
|
operationTypes: selectedType || undefined,
|
||||||
});
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pagination.reset();
|
pagination.reset()
|
||||||
}, [selectedType]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [pagination.reset])
|
||||||
|
|
||||||
function handleTypeChange(event: React.ChangeEvent<HTMLSelectElement>) {
|
function handleTypeChange(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||||
const nextType = event.target.value;
|
const nextType = event.target.value
|
||||||
setSearchParams(nextType ? { type: nextType } : {}, { replace: true });
|
setSearchParams(nextType ? { type: nextType } : {}, { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const history = operations.error ? (
|
const history = operations.error ? (
|
||||||
@ -54,7 +54,7 @@ export function BrokerOperationsPage() {
|
|||||||
onNext: () => pagination.handleNext(operations.data?.nextCursor),
|
onNext: () => pagination.handleNext(operations.data?.nextCursor),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box component="section" aria-labelledby="broker-operations-heading">
|
<Box component="section" aria-labelledby="broker-operations-heading">
|
||||||
@ -78,5 +78,5 @@ export function BrokerOperationsPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
{history}
|
{history}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { BrokerPositionsPage } from './ui/BrokerPositionsPage';
|
export { BrokerPositionsPage } from './ui/BrokerPositionsPage'
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user