diff --git a/.ai/conventions/testing.md b/.ai/conventions/testing.md index ab0b1af..3e80393 100644 --- a/.ai/conventions/testing.md +++ b/.ai/conventions/testing.md @@ -47,7 +47,7 @@ throwing route, безопасная ссылка и reset после navigation Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx), [client test](../../src/shared/api/client.test.ts), -[usePortfolio test](../../src/entities/portfolio/api/usePortfolio.test.tsx). +[resource lifecycle test](../../src/shared/api/resource-query.test.tsx). Не копируй fixtures/сценарии целиком, если задача затрагивает один исход. ## Команды diff --git a/.ai/conventions/typescript-react.md b/.ai/conventions/typescript-react.md index 3593ce5..8f02b1a 100644 --- a/.ai/conventions/typescript-react.md +++ b/.ai/conventions/typescript-react.md @@ -17,6 +17,10 @@ CSF сохраняет default meta и прямые story exports; configuration exceptions заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code. +Тип значений формы выводится из её schema, когда input/output совпадают. +Общие props определяются у одного owner; DTO и presentation model сохраняют +собственные типы при смысловом отличии. Type re-export сохраняет public imports. + ## Читаемость: ESLint и review ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между @@ -33,6 +37,10 @@ CSS и Markdown также держи в 80 колонках там, где эт Review проверяет то, чего синтаксис не доказывает: +Обоснованное config exception сопровождается причиной и явным ожиданием в +existing lint tests, включая разрешённый и запрещённый примеры. Это позволяет +настраивать профиль без случайного ослабления остальных правил. + - Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат. - Имена derived values описывают значение, например `projectedAmount`. - Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток diff --git a/.ai/conventions/ui.md b/.ai/conventions/ui.md index 69db119..7a185e7 100644 --- a/.ai/conventions/ui.md +++ b/.ai/conventions/ui.md @@ -63,6 +63,12 @@ Presentation VM для form-context/UI-only state может использов в каталоге: её граф зависимостей не достигает application data/API. `useTranslation` и декларативный вывод коллекции допустимы во view. +ESLint ограничивает прямые data/auth/router hook imports в `*.view.tsx`; +type imports, error helpers, router components и form context разрешены. +Транзитивный fetching через local presentation hook проверяется review; +Storybook дополнительно проверяет reachable graph. Новое исключение должно +сохранять presentation-only responsibility. + JSX props получают ссылки на функции, включая `onClick`, render props, `dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай `useController` в VM вместо inline `Controller.render`. Значение prop с условием @@ -76,9 +82,9 @@ JSX props получают ссылки на функции, включая `onC Closure над состоянием, mutations и refs является действием VM, а не shared helper. VM собирает связные предметные hooks, когда внутри смешиваются независимые -сценарии. Например, `useOpeningDateField` владеет draft/date parsing и событиями -даты, `usePaymentSystemField` — выбором платёжной системы. Каждый такой hook -возвращает данные и действия своей ответственности, VM объединяет их для view. +сценарии. Например, `useRefundPurchase` владеет выбором исходной покупки, +её загрузкой и пагинацией. Общий адаптер `bankDateField` обслуживает поля даты; +простое подключение одного controller остаётся в VM формы. Не группируй несвязанные функции только по префиксу `on*` и не создавай passthrough hook над одним вызовом ради формального дробления. @@ -110,8 +116,8 @@ Render-helper замени самостоятельным presentation component При переносе обновляй imports, ссылки документации и проверки, зависящие от пути. Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx) -лежит рядом со своим view, тестом и story; общий `BankForms.module.css` -остаётся в `bank-card/ui`. +лежит рядом со своим view, тестом и story. Формы используют Mantine +`Fieldset variant="unstyled"` для групповой блокировки полей без локального CSS. ## Формы diff --git a/dependency-cruiser.cjs b/dependency-cruiser.cjs index 777da44..39beab8 100644 --- a/dependency-cruiser.cjs +++ b/dependency-cruiser.cjs @@ -1,5 +1,14 @@ module.exports = { forbidden: [ + { + from: { + path: "^(src|packages/[^/]+/src)/", + pathNot: "[.](test|stories|fixtures)[.]|^src/test/", + }, + name: "production-must-not-depend-on-test-code", + severity: "error", + to: { path: "[.](test|stories|fixtures)[.]|^src/test/" }, + }, { from: {}, name: "no-unresolved", diff --git a/eslint.config.mjs b/eslint.config.mjs index 2885a5a..c467438 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -153,11 +153,13 @@ export default defineConfig( }, }, rules: { + "@typescript-eslint/consistent-type-imports": "error", // React ignores event handler return values; floating promises are still checked in bodies. "@typescript-eslint/no-misused-promises": [ "error", { checksVoidReturn: { attributes: false } }, ], + "@typescript-eslint/no-non-null-assertion": "error", "@typescript-eslint/switch-exhaustiveness-check": "error", }, }, @@ -183,6 +185,44 @@ export default defineConfig( files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"], ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"], }, + { + files: ["src/**/*.view.tsx"], + rules: { + "no-restricted-imports": ["error", { + paths: [ + { + allowTypeImports: true, + message: "Query lifecycle belongs to the data VM.", + name: "@tanstack/react-query", + }, + { + allowTypeImports: true, + message: "Transport belongs to shared/api and the data VM.", + name: "openapi-fetch", + }, + { + allowTypeImports: true, + message: "Transport belongs to shared/api and the data VM.", + name: "openapi-react-query", + }, + ], + patterns: [ + { + allowTypeImports: true, + group: ["react-router", "@/entities/*", "@/features/*"], + importNamePattern: "^use", + message: "Pass route, auth and entity data from the data VM.", + }, + { + allowTypeImports: true, + group: ["@/shared/api", "@/shared/api/*"], + importNamePattern: "^use|^api(?:Query)?Client$", + message: "Fetching belongs to the data VM; error helpers are allowed.", + }, + ], + }], + }, + }, { files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"], ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"], @@ -197,6 +237,7 @@ export default defineConfig( ignoreTypeIndexes: true, }, ], + "@typescript-eslint/no-unnecessary-condition": ["error", { allowConstantLoopConditions: true }], }, }, { diff --git a/package-lock.json b/package-lock.json index 4fb6f4f..5901fb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4082,9 +4082,9 @@ "license": "MIT" }, "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -6942,9 +6942,9 @@ } }, "node_modules/basic-ftp": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", - "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.2.1.tgz", + "integrity": "sha512-bK67isD+lKq46AU8vNtjvMaT2ZqAOAmNCbxUHlFBRD4k15NWxyEjmaKtZPlgce58So4BNTjITGQOVTjL9y0ECA==", "dev": true, "license": "MIT", "engines": { @@ -6969,9 +6969,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9668,9 +9668,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "devOptional": true, "funding": [ { @@ -10702,9 +10702,9 @@ } }, "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", "dev": true, "license": "MIT", "engines": { @@ -14291,9 +14291,9 @@ } }, "node_modules/release-it/node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", "dev": true, "license": "MIT", "engines": { @@ -14673,9 +14673,9 @@ } }, "node_modules/serialize-javascript": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.1.tgz", - "integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz", + "integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/package.json b/package.json index 462018f..dcf0843 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "openapi:check": "node scripts/check-openapi.mjs", "knip": "npm run build:packages && knip", "deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long", - "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry", + "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && eslint . --max-warnings=0 && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry", "prepare": "husky", "preview": "vite preview", "deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long", @@ -114,6 +114,12 @@ "vite-plugin-pwa": "1.3.0", "vitest": "5.0.0" }, + "overrides": { + "release-it": { + "basic-ftp": "6.2.1", + "undici": "7.29.1" + } + }, "allowScripts": { "esbuild@0.28.2": true, "fsevents@2.3.3": true diff --git a/packages/icons/tsconfig.json b/packages/icons/tsconfig.json index 77d9d3e..a937da9 100644 --- a/packages/icons/tsconfig.json +++ b/packages/icons/tsconfig.json @@ -1,15 +1,12 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { - "target": "ES2023", "lib": [ "ES2023", "DOM" ], "module": "ESNext", "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "verbatimModuleSyntax": true, "declaration": true, "emitDeclarationOnly": true, "rootDir": "src", diff --git a/packages/ui/src/EntryCard/EntryCard.browser.test.tsx b/packages/ui/src/EntryCard/EntryCard.browser.test.tsx new file mode 100644 index 0000000..7238790 --- /dev/null +++ b/packages/ui/src/EntryCard/EntryCard.browser.test.tsx @@ -0,0 +1,38 @@ +import "@mantine/core/styles.css"; + +import "../styles.css"; + +import { MantineProvider } from "@mantine/core"; +// eslint-disable-next-line testing-library/no-manual-cleanup -- Vitest globals are disabled, so RTL cannot register its automatic afterEach cleanup. +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; + +import { cssVariablesResolver, theme } from "../theme"; +import { EntryCard } from "./EntryCard"; + +afterEach(cleanup); + +it("shows the full wrapped title without horizontal overflow", () => { + const title = "Дивидендная выплата с очень длинным описанием, которое должно переноситься и оставаться доступным полностью"; + + render( + + + , + ); + + const description = screen.getByText(title); + expect(description.clientHeight).toBeGreaterThan(0); + expect(description.scrollHeight) + .toBeLessThanOrEqual(description.clientHeight); + expect(document.documentElement.scrollWidth) + .toBeLessThanOrEqual(window.innerWidth); +}); diff --git a/packages/ui/src/EntryCard/EntryCard.module.css b/packages/ui/src/EntryCard/EntryCard.module.css index 2aca6de..fbdcf96 100644 --- a/packages/ui/src/EntryCard/EntryCard.module.css +++ b/packages/ui/src/EntryCard/EntryCard.module.css @@ -93,3 +93,13 @@ transition: none; } } + +.row[data-wrap] p { + white-space: normal; + overflow-wrap: anywhere; +} +@media (min-width: 36em) { + .row[data-wrap] { + grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%); + } +} diff --git a/packages/ui/src/EntryCard/EntryCard.stories.tsx b/packages/ui/src/EntryCard/EntryCard.stories.tsx index 3d06295..063520c 100644 --- a/packages/ui/src/EntryCard/EntryCard.stories.tsx +++ b/packages/ui/src/EntryCard/EntryCard.stories.tsx @@ -39,3 +39,5 @@ export default meta; type Story = StoryObj; export const LongContentAndFallback: Story = {}; + +export const WrappedOperation: Story = { args: { value: "−123 456 789 012 345 678,90 ₽", wrap: true } }; diff --git a/packages/ui/src/EntryCard/EntryCard.tsx b/packages/ui/src/EntryCard/EntryCard.tsx index 9f5d9c0..973c8ef 100644 --- a/packages/ui/src/EntryCard/EntryCard.tsx +++ b/packages/ui/src/EntryCard/EntryCard.tsx @@ -18,6 +18,7 @@ type EntryCardProps = { | "positive"; value: string; valueLabel: string; + wrap?: boolean; }; const EntryCard: FC = (props) => { @@ -27,11 +28,18 @@ const EntryCard: FC = (props) => { } : undefined; + const titleLineClamp = props.wrap ? undefined : 1; + const valueContainerClass = props.actions ? classes.valueFooter : undefined; const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`; return ( - + - + {props.title} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index b287378..e326f7e 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -1,13 +1,10 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { - "target": "ES2023", "lib": ["ES2023", "DOM"], "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, - "strict": true, - "skipLibCheck": true, - "verbatimModuleSyntax": true, "declaration": true, "emitDeclarationOnly": true, "rootDir": "src", diff --git a/scripts/test-eslint.mjs b/scripts/test-eslint.mjs index 3d383d1..65fdbe7 100644 --- a/scripts/test-eslint.mjs +++ b/scripts/test-eslint.mjs @@ -24,6 +24,21 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as const eslint = new ESLint(); for (const [filePath, code, rule] of [ + [ + "src/shared/api/problem-details.ts", + "export function read(value: string) { return value?.trim(); }", + "@typescript-eslint/no-unnecessary-condition", + ], + [ + "src/shared/api/problem-details.ts", + "export function read(value?: string) { return value!.trim(); }", + "@typescript-eslint/no-non-null-assertion", + ], + [ + "src/shared/api/problem-details.ts", + "import { ReactNode } from \"react\"; export type Content = ReactNode;", + "@typescript-eslint/consistent-type-imports", + ], [ "src/shared/api/problem-details.ts", "export function run() { Promise.resolve(); }", @@ -323,6 +338,46 @@ test("JSX spacing fixes preserve text interpolation and are stable", async () => assert.equal(again.errorCount, 0); }); +test("views accept presentation inputs but reject direct data hooks", async () => { + const eslint = new ESLint(); + const filePath = "src/features/instrument-details/ui/InstrumentEvents.view.tsx"; + + for (const code of [ + "import { useStock } from \"@/entities/stock\";", + "import { useAuth } from \"@/features/auth\";", + "import { useNavigate } from \"react-router\";", + "import { apiClient } from \"@/shared/api\";", + "import { useQuery } from \"@tanstack/react-query\";", + ]) { + const [result] = await eslint.lintText(code, { filePath }); + assert.equal(result.fatalErrorCount, 0); + assert.ok(result.messages.some(message => + message.ruleId === "no-restricted-imports")); + } + + for (const code of [ + "import { Link } from \"react-router\";", + "import { apiErrorCode } from \"@/shared/api\";", + "import type { StockPosition } from \"@/entities/stock\";", + "import type { QueryKey } from \"@tanstack/react-query\";", + "import { useTranslation } from \"react-i18next\";", + "import { useFormContext } from \"react-hook-form\";", + ]) { + const [result] = await eslint.lintText(code, { filePath }); + assert.equal(result.fatalErrorCount, 0); + assert.ok(result.messages.every(message => + message.ruleId !== "no-restricted-imports")); + } + + const [vm] = await eslint.lintText( + "import { useStock } from \"@/entities/stock\";", + { filePath: "src/pages/stock/ui/StockPage/StockPage.vm.ts" }, + ); + + assert.ok(vm.messages.every(message => + message.ruleId !== "no-restricted-imports")); +}); + test("compact JSX and contract nulls need no local suppressions", async () => { const eslint = new ESLint({ fix: true }); const filePath = "packages/ui/src/EntryCard/EntryCard.tsx"; diff --git a/src/app/model/index.ts b/src/app/model/index.ts new file mode 100644 index 0000000..9b1e264 --- /dev/null +++ b/src/app/model/index.ts @@ -0,0 +1 @@ +export { useCurrentRoute } from "./useCurrentRoute"; diff --git a/src/app/model/useCurrentRoute.ts b/src/app/model/useCurrentRoute.ts new file mode 100644 index 0000000..4204d82 --- /dev/null +++ b/src/app/model/useCurrentRoute.ts @@ -0,0 +1,23 @@ +import { useMatch } from "react-router"; + +function useCurrentRoute() { + const account = useMatch("/accounts/:accountId/:section?"); + + const section = (["events", "income", "operations"] as const) + .find(value => value === account?.params.section?.toLowerCase()); + + return { + account, + accounts: useMatch("/accounts"), + bond: useMatch("/bonds/:bondId"), + bonds: useMatch("/bonds"), + card: useMatch("/cards/:cardId"), + cards: useMatch("/cards"), + section, + settings: useMatch("/settings"), + stock: useMatch("/stocks/:stockId"), + stocks: useMatch("/stocks"), + }; +} + +export { useCurrentRoute }; diff --git a/src/app/routes/AppRoutes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx index 62a849a..023debe 100644 --- a/src/app/routes/AppRoutes/AllAccounts.test.tsx +++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx @@ -238,6 +238,34 @@ describe("All accounts routes", () => { ); }); + it.each(["operations", "events", "income"])( + "restores applied dates when switching accounts in %s", + async (section) => { + const search = "?from=2026-08-01&to=2026-08-31"; + renderPage(`/accounts/all/${section}${search}`); + fireEvent.click(await screen.findByLabelText("Период")); + fireEvent.click( + await screen.findByRole("button", { name: /10 August 2026/i }), + ); + fireEvent.click( + await screen.findByRole("button", { name: /31 August 2026/i }), + ); + expect(screen.getByLabelText("Период")).toHaveTextContent( + "10.08.2026 – 31.08.2026", + ); + fireEvent.click(screen.getByRole("combobox", { name: "Счёт" })); + fireEvent.click(await screen.findByRole("option", { name: "ИИС" })); + await waitFor(() => + expect(screen.getByLabelText("Период")).toHaveTextContent( + "01.08.2026 – 31.08.2026", + ), + ); + expect(screen.getByLabelText("Текущий путь")).toHaveTextContent( + `/accounts/b/${section}${search}`, + ); + }, + ); + it("groups the same security and links its accounts and overview sections", async () => { renderPage("/accounts/all"); expect(await screen.findAllByText("Сбербанк")).toHaveLength(1); @@ -312,6 +340,11 @@ describe("All accounts routes", () => { expect(screen.getByLabelText("Текущий путь")).toHaveTextContent( "/accounts/b/operations?from=2026-08-01&to=2026-08-31&categories=income", ); + await screen.findByText("Первая страница"); + fireEvent.click(screen.getByRole("combobox", { name: "Счёт" })); + fireEvent.click(await screen.findByRole("option", { name: "Все счета" })); + await waitFor(() => expect(screen.getByText("Первая страница")).toBeVisible()); + expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled(); }); it("restarts an expired aggregate cursor without losing filters", async () => { diff --git a/src/app/routes/AppRoutes/AppRoutes.test.tsx b/src/app/routes/AppRoutes/AppRoutes.test.tsx index cbc1698..40990f6 100644 --- a/src/app/routes/AppRoutes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes/AppRoutes.test.tsx @@ -1,9 +1,11 @@ +import type { ReactNode } from "react"; + import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen } from "@testing-library/react"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { http, HttpResponse } from "msw"; -import { MemoryRouter } from "react-router"; +import { type InitialEntry, Link, MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; import { AuthProvider } from "@/features/auth"; @@ -12,10 +14,11 @@ import { server } from "@/test/server"; import { AppRoutes } from "./AppRoutes"; -function renderRoutes(path: string) { +function renderRoutes(path: InitialEntry, controls?: ReactNode) { return render( + {controls} { ).toBeInTheDocument(); }); - it("logs in through the public form and reaches the protected landing", async () => { + it.each([ + { endpoint: "login", path: "/accounts", title: "Вход" }, + { endpoint: "register", path: "/register", title: "Регистрация" }, + { + endpoint: "login", + path: { pathname: "/login", state: { returnTo: "//example.com" } }, + title: "Вход", + }, + { endpoint: "login", path: { pathname: "/login", state: { returnTo: 42 } }, title: "Вход" }, + { endpoint: "login", path: { pathname: "/login", state: 42 }, title: "Вход" }, + ])("authenticates via $endpoint and reaches the protected landing from $path", async ({ endpoint, path, title }) => { let credentials: unknown; server.use( - http.post("*/api/v1/auth/login", async ({ request }) => { + http.post(`*/api/v1/auth/${endpoint}`, async ({ request }) => { credentials = await request.json(); return HttpResponse.json({ @@ -69,16 +82,23 @@ describe("AppRoutes", () => { }); }), ); - renderRoutes("/accounts"); + renderRoutes(path); - await screen.findByRole("heading", { level: 1, name: "Вход" }); + await screen.findByRole("heading", { level: 1, name: title }); fireEvent.change(screen.getByLabelText("Email"), { target: { value: " User@Example.com " }, }); fireEvent.change(screen.getByLabelText("Пароль"), { target: { value: "a secure password" }, }); - fireEvent.click(screen.getByRole("button", { name: "Вход" })); + + if (endpoint === "register") { + fireEvent.change(screen.getByLabelText("Подтвердите пароль"), { + target: { value: "a secure password" }, + }); + } + + fireEvent.click(screen.getByRole("button", { name: title })); expect( await screen.findByRole("heading", { @@ -93,6 +113,43 @@ describe("AppRoutes", () => { }); }); + it.each([ + { isFailure: false, isNewDestination: false }, + { isFailure: true, isNewDestination: false }, + { isFailure: true, isNewDestination: true }, + ])("finishes logout safely: failure=$isFailure, new destination=$isNewDestination", async ({ isFailure, isNewDestination }) => { + const { promise: pendingLogout, resolve: finishLogout } + = Promise.withResolvers(); + + server.use( + http.post("*/api/v1/auth/refresh", () => HttpResponse.json({ + accessToken: "test-token", user: { email: "user@example.com", id: "user-1" }, + })), + http.post("*/api/v1/auth/logout", async () => { + await pendingLogout; + + return isFailure + ? HttpResponse.error() + : new HttpResponse(null, { status: 204 }); + }), + ); + renderRoutes("/settings", Открыть настройки); + fireEvent.click(await screen.findByRole("button", { name: "Выйти" })); + await screen.findByRole("heading", { level: 1, name: "Вход" }); + if (isNewDestination) fireEvent.click(screen.getByRole("link", { name: "Открыть настройки" })); + fireEvent.change(screen.getByLabelText("Email"), { target: { value: "user@example.com" } }); + fireEvent.change(screen.getByLabelText("Пароль"), { target: { value: "a secure password" } }); + fireEvent.click(screen.getByRole("button", { name: "Вход" })); + finishLogout(); + + const destination = isNewDestination + ? { level: 1, name: "Настройки" } + : { level: 2, name: "Нет инвестиционных счетов" }; + + expect(await screen.findByRole("heading", destination)).toBeInTheDocument(); + expect(document.title).toBe(isNewDestination ? "Настройки — WealthLedger" : "Счета — WealthLedger"); + }); + it("returns after login to cards with the selected month without T-Bank", async () => { server.use( http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([])), diff --git a/src/app/routes/AppRoutes/AppRoutes.tsx b/src/app/routes/AppRoutes/AppRoutes.tsx index b86289e..fea6244 100644 --- a/src/app/routes/AppRoutes/AppRoutes.tsx +++ b/src/app/routes/AppRoutes/AppRoutes.tsx @@ -200,42 +200,6 @@ const AppRoutes: FC = () => ( path="/accounts" /> - - - - )} - path="/accounts/all" - /> - - - - - )} - path="/accounts/all/operations" - /> - - - - - )} - path="/accounts/all/income" - /> - - - - - )} - path="/accounts/all/events" - /> - diff --git a/src/app/routes/DocumentTitle/DocumentTitle.tsx b/src/app/routes/DocumentTitle/DocumentTitle.tsx index 2c3caa4..61d25f0 100644 --- a/src/app/routes/DocumentTitle/DocumentTitle.tsx +++ b/src/app/routes/DocumentTitle/DocumentTitle.tsx @@ -2,8 +2,8 @@ import type { FC } from "react"; import { useDocumentTitle } from "@mantine/hooks"; import { useTranslation } from "react-i18next"; -import { useMatch } from "react-router"; +import { useCurrentRoute } from "@/app/model"; import { useBankCard } from "@/entities/bank-card"; import { useBond } from "@/entities/bond"; import { useBrokerageAccounts } from "@/entities/brokerage-account"; @@ -82,14 +82,18 @@ const CardDocumentTitle: FC = (props) => { const ProtectedDocumentTitle: FC = () => { const { t } = useTranslation(); - const account = useMatch("/accounts/:accountId/:section?"); - const bond = useMatch("/bonds/:bondId"); - const stock = useMatch("/stocks/:stockId"); - const card = useMatch("/cards/:cardId"); - const cards = useMatch("/cards"); - const bonds = useMatch("/bonds"); - const stocks = useMatch("/stocks"); - const settings = useMatch("/settings"); + + const { + account, + bond, + bonds, + card, + cards, + settings, + stock, + stocks, + } = useCurrentRoute(); + if (card) return ; if (account) return ( diff --git a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx index bb63f50..5dd8aea 100644 --- a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx +++ b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx @@ -24,15 +24,15 @@ class Boundary extends Component { return { hasError: true }; } - state: State = { hasError: false }; + override state: State = { hasError: false }; - componentDidUpdate(previousProps: BoundaryProps) { + override componentDidUpdate(previousProps: BoundaryProps) { if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) { this.setState({ hasError: false }); } } - render() { + override render() { return this.state.hasError ? : ( diff --git a/src/app/routes/RouteFallback.tsx b/src/app/routes/RouteFallback.tsx index a2b71cf..20e3f9c 100644 --- a/src/app/routes/RouteFallback.tsx +++ b/src/app/routes/RouteFallback.tsx @@ -1,6 +1,7 @@ import type { FC, PropsWithChildren } from "react"; import { Container, Group, Skeleton, Stack } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; import { Suspense } from "react"; import { useTranslation } from "react-i18next"; @@ -19,11 +20,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { if (variant === "auth") return ( - + @@ -31,7 +28,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { - + ); @@ -62,11 +59,11 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { ); return ( - + - + ); }; diff --git a/src/app/routes/RouteGuards.tsx b/src/app/routes/RouteGuards.tsx index 2d57663..5bdff06 100644 --- a/src/app/routes/RouteGuards.tsx +++ b/src/app/routes/RouteGuards.tsx @@ -1,5 +1,4 @@ -import type { FC, PropsWithChildren } from "react"; - +import { type FC, type PropsWithChildren, useState } from "react"; import { Navigate, useLocation } from "react-router"; import { useAuth } from "@/features/auth"; @@ -8,16 +7,22 @@ import { RouteSkeleton } from "./RouteFallback"; const Protected: FC = (props) => { const { children } = props; - const { ready, user } = useAuth(); + const { ready, signedOut, user } = useAuth(); const location = useLocation(); + const [wasAuthenticated, setWasAuthenticated] = useState(false); + if (user && !wasAuthenticated) setWasAuthenticated(true); if (!ready) return ; + const loginState = signedOut && wasAuthenticated + ? undefined + : { returnTo: `${location.pathname}${location.search}` }; + return user ? <>{children} : ( ); @@ -30,8 +35,11 @@ const Public: FC = (props) => { if (!ready) return ; - const requested = (location.state as null - | { returnTo?: string })?.returnTo; + const state: unknown = location.state; + + const requested = state && typeof state === "object" && "returnTo" in state + ? state.returnTo + : undefined; const returnTo = typeof requested === "string" diff --git a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.test.tsx b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.test.tsx index cb82e76..e08231d 100644 --- a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.test.tsx +++ b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.test.tsx @@ -54,8 +54,13 @@ describe("AppBreadcrumbs", () => { expect(screen.queryByRole("link")).not.toBeInTheDocument(); }); - it("links to the account hierarchy from operations", () => { - renderBreadcrumbs("/accounts/account-1/operations", [ + it.each([ + ["operations", "Операции"], + ["OPERATIONS", "Операции"], + ["events", "Будущие события"], + ["income", "Пассивный доход"], + ])("links to the account hierarchy from %s", (section, title) => { + renderBreadcrumbs(`/accounts/account-1/${section}`, [ { id: "account-1", name: "ИИС" }, ]); @@ -66,7 +71,7 @@ describe("AppBreadcrumbs", () => { "href", "/accounts/account-1", ); - expect(screen.getByText("Операции")).toHaveAttribute( + expect(screen.getByText(title)).toHaveAttribute( "aria-current", "page", ); @@ -86,7 +91,11 @@ describe("AppBreadcrumbs", () => { expect(screen.queryByText("account-1")).not.toBeInTheDocument(); }); - it.each(["/login", "/unknown"])("does not render on %s", (path) => { + it.each([ + "/login", + "/unknown", + "/accounts/account-1/unknown", + ])("does not render on %s", (path) => { renderBreadcrumbs(path); expect( diff --git a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx index 5f178af..b21d084 100644 --- a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx +++ b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx @@ -2,7 +2,8 @@ import type { FC } from "react"; import { Stack } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { useMatch } from "react-router"; + +import { useCurrentRoute } from "@/app/model"; import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch"; @@ -14,74 +15,34 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs"; const AppBreadcrumbs: FC = () => { const { t } = useTranslation(); - const operations = useMatch({ - end: true, - path: "/accounts/:accountId/operations", - }); + const { + account, + accounts, + bond, + card, + cards, + section, + settings, + stock, + } = useCurrentRoute(); - const events = useMatch({ end: true, path: "/accounts/:accountId/events" }); - const income = useMatch({ end: true, path: "/accounts/:accountId/income" }); - const account = useMatch({ end: true, path: "/accounts/:accountId" }); - const card = useMatch("/cards/:cardId"); - const cards = useMatch("/cards"); - const accounts = useMatch({ end: true, path: "/accounts" }); - const settings = useMatch({ end: true, path: "/settings" }); - - const stock = useMatch({ end: true, path: "/stocks/:stockId" }); - const bond = useMatch({ end: true, path: "/bonds/:bondId" }); if (bond) return ; if (stock) return ; - if (operations) + if (account && (section || !account.params.section)) return ( ); - if (events) - return ( - - - - - - ); - if (income) - return ( - - - - - - ); - if (account) - return ( - - - - - - ); if (card) return ; if (cards) return ; if (accounts) diff --git a/src/entities/bank-card/api/banking.test.tsx b/src/entities/bank-card/api/banking.test.tsx index 4829b3a..2ec2666 100644 --- a/src/entities/bank-card/api/banking.test.tsx +++ b/src/entities/bank-card/api/banking.test.tsx @@ -6,6 +6,8 @@ import { apiClient, configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { server } from "@/test/server"; +import type { BankCard, CreateBankOperation } from "./types"; + import { useCreateBankOperation, useDeleteBankOperation, @@ -14,8 +16,6 @@ import { useUpdateExpenseCategory, } from "./mutations"; import { - type BankCard, - type CreateBankOperation, useBankCard, useBankCards, useBankCategories, @@ -106,14 +106,14 @@ function useReadModels() { const categories = useBankCategories(); const history = useBankOperations("a", { - limit: 20, - month: "2026-09", - page: 2, + from: "2026-09-01", + limit: 20, page: 2, + to: "2026-09-30", }); const expenses = useBankExpenses("a", { page: 1 }); - const september = useBankSummary("2026-09"); - const august = useBankSummary("2026-08", "b"); + const september = useBankSummary({ from: "2026-09-01", to: "2026-09-30" }); + const august = useBankSummary({ from: "2026-08-01", to: "2026-08-31" }, "b"); const original = useBankOperation("original"); return { @@ -144,7 +144,8 @@ describe("banking API", () => { const page = Number(url.searchParams.get("page")); pages.push(page); expect(url.searchParams.get("limit")).toBe("100"); - expect(url.searchParams.get("month")).toBe("2026-09"); + expect(url.searchParams.get("from")).toBe("2026-09-01"); + expect(url.searchParams.get("to")).toBe("2026-09-30"); if (isFail && page === 2) return HttpResponse.json({ code: "FAILED" }, { status: 500 }); @@ -158,7 +159,7 @@ describe("banking API", () => { ); const { result } = renderHook( - () => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }), + () => useBankOperations("a", { from: "2026-09-01", limit: "all", page: 1, to: "2026-09-30" }), { wrapper: createQueryWrapper().wrapper }, ); @@ -171,7 +172,7 @@ describe("banking API", () => { isFail = true; const { result: failedResult } = renderHook( - () => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }), + () => useBankOperations("a", { from: "2026-09-01", limit: "all", page: 1, to: "2026-09-30" }), { wrapper: createQueryWrapper().wrapper }, ); @@ -179,7 +180,7 @@ describe("banking API", () => { expect(failedResult.current.data).toBeUndefined(); }); - it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => { + it("uses session-aware banking endpoints and explicit period/page without T-Bank", async () => { const counts = new Map(); server.use(...readHandlers(counts)); @@ -191,12 +192,15 @@ describe("banking API", () => { expect(result.current.reads.every(read => read.isSuccess)).toBe(true), ); expect(counts.keys()).toContain("/api/v1/bank-cards?includeArchived=true"); - expect(counts.keys()).toContain( - "/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2", - ); - expect(counts.keys()).toContain( - "/api/v1/bank-cards/summary?cardId=b&month=2026-08", - ); + const urls = Array.from(counts.keys(), path => new URL(path, "http://localhost")); + const history = urls.find(url => url.pathname === "/api/v1/bank-cards/a/operations"); + const summary = urls.find(url => url.pathname === "/api/v1/bank-cards/summary" && url.searchParams.get("cardId") === "b"); + expect(Object.fromEntries(history?.searchParams ?? [])).toEqual({ + from: "2026-09-01", limit: "20", page: "2", to: "2026-09-30", + }); + expect(Object.fromEntries(summary?.searchParams ?? [])).toEqual({ + cardId: "b", from: "2026-08-01", to: "2026-08-31", + }); expect(counts.size).toBe(9); }); @@ -303,7 +307,7 @@ describe("banking API", () => { expect(result.current.query.data?.balance.value).toBe("100"); }); - it("refetches both cards, every month, categories, journal and source expenses after a transfer without invalidating brokerage", async () => { + it("refetches both cards, every period, categories, journal and source expenses after a transfer without invalidating brokerage", async () => { const counts = new Map(); server.use( ...readHandlers(counts), @@ -403,7 +407,7 @@ describe("banking API", () => { ); await waitFor(() => - expect(result.current.categories.data?.[0].name).toBe("Food"), + expect(result.current.categories.data?.[0]?.name).toBe("Food"), ); await act(() => result.current.updateCategory.mutateAsync({ @@ -412,7 +416,7 @@ describe("banking API", () => { }), ); await waitFor(() => - expect(result.current.categories.data?.[0].name).toBe("Groceries"), + expect(result.current.categories.data?.[0]?.name).toBe("Groceries"), ); await act(() => result.current.updateOperation.mutateAsync({ diff --git a/src/entities/bank-card/api/mutations.ts b/src/entities/bank-card/api/mutations.ts index 5efa70e..2d46cc6 100644 --- a/src/entities/bank-card/api/mutations.ts +++ b/src/entities/bank-card/api/mutations.ts @@ -14,7 +14,7 @@ import type { UpdateBankCard, UpdateBankOperation, UpdateExpenseCategory, -} from "./queries"; +} from "./types"; async function mutationData( request: Promise<{ data?: T; error?: unknown }>, @@ -46,16 +46,16 @@ function useBankMutationOptions() { return { onError: (error: unknown) => { const errorCode = apiErrorCode(error); - if ( - [ - ApiErrorCode.BankDependentRefunds, - ApiErrorCode.BankIntentConflict, - ApiErrorCode.BankRefundLimit, - ApiErrorCode.BankVersionConflict, - ApiErrorCode.BankWriteConflict, - ].some(code => isApiErrorCode(errorCode, code)) - ) - return invalidate(); + + return [ + ApiErrorCode.BankDependentRefunds, + ApiErrorCode.BankIntentConflict, + ApiErrorCode.BankRefundLimit, + ApiErrorCode.BankVersionConflict, + ApiErrorCode.BankWriteConflict, + ].some(code => isApiErrorCode(errorCode, code)) + ? invalidate() + : undefined; }, onSuccess: invalidate, retry: false, diff --git a/src/entities/bank-card/api/queries.ts b/src/entities/bank-card/api/queries.ts index e97e28a..436bb84 100644 --- a/src/entities/bank-card/api/queries.ts +++ b/src/entities/bank-card/api/queries.ts @@ -1,44 +1,12 @@ import { apiClient, apiQueryClient, - type paths, useAuthenticatedQuery, } from "@/shared/api"; -import { moneyCents } from "../model/form-validation"; +import type { BankOperation, BankOperations, BankSummary } from "./types"; -type BankCard - = paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"]; - -type BankOperation - = paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"]; - -type BankOperations - = paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"]; - -type BankSummary - = paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"]; - -type CreateBankCard - = paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"]; - -type CreateBankOperation - = paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"]; - -type CreateExpenseCategory - = paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"]; - -type ExpenseCategory - = paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number]; - -type UpdateBankCard - = paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"]; - -type UpdateBankOperation - = paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"]; - -type UpdateExpenseCategory - = paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"]; +import { moneyCents } from "../model/money"; const OPERATIONS_PAGE_SIZE = 100; @@ -110,7 +78,6 @@ function useBankOperations( query: { from?: string; limit?: "all" | number; - month?: string; page: number; to?: string; }, @@ -134,7 +101,7 @@ function useBankOperations( // eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity. return useAuthenticatedQuery({ - enabled: Boolean(id && (query.month || (query.from && query.to))), + enabled: Boolean(id && query.from && query.to), placeholderData: ( previousData: BankOperations | undefined, previousQuery, @@ -143,8 +110,7 @@ function useBankOperations( return !isAll && previousQuery?.queryKey.length === options.queryKey.length - && previous?.path?.id === id - && previous?.query?.month === query.month + && previous?.path.id === id && previous?.query?.from === query.from && previous?.query?.to === query.to && previous?.query?.limit === query.limit @@ -165,6 +131,7 @@ function useBankOperations( // eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers. if (error) throw error; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- An empty HTTP body can violate the generated response contract. if (!data) throw new Error("Missing bank operations response"); if (!isAll) return data; items.push(...data.items); @@ -178,36 +145,19 @@ function useBankOperations( } function useBankSummary( - period: string - | { from: string; to: string }, + period: { from: string; to: string }, cardId?: string, ) { - const query - = typeof period === "string" - ? { cardId, month: period } - : { ...period, cardId }; - return useAuthenticatedQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", { - params: { query }, + params: { query: { ...period, cardId } }, }), - enabled: Boolean(period), select: sortBankSummary, }); } export { - type BankCard, - type BankOperation, - type BankSummary, - type CreateBankCard, - type CreateBankOperation, - type CreateExpenseCategory, - type ExpenseCategory, sortBankSummary, - type UpdateBankCard, - type UpdateBankOperation, - type UpdateExpenseCategory, useBankCard, useBankCards, useBankCategories, diff --git a/src/entities/bank-card/api/summary.test.ts b/src/entities/bank-card/api/summary.test.ts index 2175a8c..100554c 100644 --- a/src/entities/bank-card/api/summary.test.ts +++ b/src/entities/bank-card/api/summary.test.ts @@ -1,6 +1,8 @@ import { expect, it } from "vitest"; -import { type BankSummary, sortBankSummary } from "./queries"; +import type { BankSummary } from "./types"; + +import { sortBankSummary } from "./queries"; const money = (value: string) => ({ currency: "RUB", value }); diff --git a/src/entities/bank-card/api/types.ts b/src/entities/bank-card/api/types.ts new file mode 100644 index 0000000..f4dc250 --- /dev/null +++ b/src/entities/bank-card/api/types.ts @@ -0,0 +1,48 @@ +import type { paths } from "@/shared/api"; + +type BankCard + = paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"]; + +type BankOperation + = paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"]; + +type BankOperations + = paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"]; + +type BankSummary + = paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"]; + +type CreateBankCard + = paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"]; + +type CreateBankOperation + = paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"]; + +type CreateExpenseCategory + = paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"]; + +type ExpenseCategory + = paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number]; + +type UpdateBankCard + = paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"]; + +type UpdateBankOperation + = paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"]; + +type UpdateExpenseCategory + = paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"]; + +export type { + BankCard, + BankOperation, + BankOperations, + BankSummary, + CreateBankCard, + CreateBankOperation, + CreateExpenseCategory, + ExpenseCategory, + UpdateBankCard, + UpdateBankOperation, + UpdateExpenseCategory, +}; diff --git a/src/entities/bank-card/index.ts b/src/entities/bank-card/index.ts index 9714f53..eec8d7d 100644 --- a/src/entities/bank-card/index.ts +++ b/src/entities/bank-card/index.ts @@ -5,8 +5,6 @@ export { useUpdateBankOperation, } from "./api/mutations"; export { - type BankCard, - type BankOperation, useBankCard, useBankCards, useBankCategories, @@ -15,14 +13,16 @@ export { useBankOperations, useBankSummary, } from "./api/queries"; -export { bankDateInputValue, bankMoneyValue, bankToday, moneyCents } from "./model/form-validation"; +export type { BankCard, BankOperation } from "./api/types"; +export { bankToday } from "./model/form-validation"; export { bankDateSchema, positiveMoneySchema } from "./model/form.schema"; - +export { bankMoneyValue, moneyCents } from "./model/money"; export { bankOperationAppearance } from "./model/operation-appearance"; export { bankOperationKinds } from "./model/operation-kinds"; export { bankPeriod } from "./model/period"; export { BankCardFace } from "./ui/BankCardFace/BankCardFace"; export { BankCardForm } from "./ui/BankCardForm/BankCardForm"; +export { bankDateField } from "./ui/bankDateField"; export { BankFeedback } from "./ui/BankFeedback"; export { BankPeriodPicker } from "./ui/BankPeriodPicker/BankPeriodPicker"; export { BankSummaryView } from "./ui/BankSummary/BankSummary.view"; diff --git a/src/entities/bank-card/model/category.schema.ts b/src/entities/bank-card/model/category.schema.ts index f485f91..f8c4369 100644 --- a/src/entities/bank-card/model/category.schema.ts +++ b/src/entities/bank-card/model/category.schema.ts @@ -2,9 +2,11 @@ import { z } from "zod"; import { validationMessage } from "@/shared/i18n"; -import type { ExpenseCategory } from "../api/queries"; +import type { ExpenseCategory } from "../api/types"; const MAX_CATEGORY_NAME_LENGTH = 80; +type CategoryValues = z.infer>; + function categorySchema(categories?: ExpenseCategory[], editingId?: string) { return z.object({ name: z @@ -27,4 +29,4 @@ function categorySchema(categories?: ExpenseCategory[], editingId?: string) { }); } -export { categorySchema }; +export { categorySchema, type CategoryValues }; diff --git a/src/entities/bank-card/model/form-validation.test.ts b/src/entities/bank-card/model/form-validation.test.ts index a99a3c5..1361bc0 100644 --- a/src/entities/bank-card/model/form-validation.test.ts +++ b/src/entities/bank-card/model/form-validation.test.ts @@ -1,7 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { bankDateInputValue, bankMoneyValue, bankMonth, bankToday } from "./form-validation"; -import { bankCardSchema, bankDateSchema, positiveMoneySchema } from "./form.schema"; +import { bankDateInputValue, bankMonth, bankToday } from "./form-validation"; +import { + bankCardSchema, + bankDateSchema, + positiveMoneySchema, +} from "./form.schema"; +import { bankMoneyValue } from "./money"; afterEach(() => vi.useRealTimers()); describe("bank form input", () => { @@ -51,6 +56,9 @@ describe("bank form input", () => { expect(bankDateSchema.safeParse("2026-09-01").success).toBe(true); expect(bankDateSchema.safeParse("2026-09-02").success).toBe(false); expect(bankDateSchema.safeParse("2026-02-30").success).toBe(false); + expect(bankDateSchema.safeParse("0000-01-01").success).toBe(false); + expect(bankDateSchema.safeParse("0001-01-01").success).toBe(true); + expect(bankDateSchema.safeParse("0099-12-31").success).toBe(true); }); }); diff --git a/src/entities/bank-card/model/form-validation.ts b/src/entities/bank-card/model/form-validation.ts index 56f11db..c9fd230 100644 --- a/src/entities/bank-card/model/form-validation.ts +++ b/src/entities/bank-card/model/form-validation.ts @@ -1,5 +1,4 @@ -const DECIMAL_PART_COUNT = 2; -const CENTS_PRECISION = 2; +import { moscowDate } from "@/shared/lib"; const ISO_MONTH_LENGTH = 7; @@ -7,13 +6,6 @@ function bankDateInputValue(value: string) { return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1"); } -function bankMoneyValue(value: string) { - return value - .trim() - .replace(",", ".") - .replace(/^(-?)0+(?=\d)/, "$1"); -} - function bankMonth(value: null | string) { return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value) ? value @@ -21,27 +13,7 @@ function bankMonth(value: null | string) { } function bankToday() { - return new Intl.DateTimeFormat("en-CA", { - day: "2-digit", - month: "2-digit", - timeZone: "Europe/Moscow", - year: "numeric", - }).format(new Date()); + return moscowDate(new Date()); } -function moneyCents(value: string): bigint | null { - const normalized = value.trim().replace(",", "."); - if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized)) - return null; - - const [whole, fraction = ""] = normalized - .replace("-", "") - .split(".", DECIMAL_PART_COUNT); - - return ( - BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`) - * (normalized.startsWith("-") ? -1n : 1n) - ); -} - -export { bankDateInputValue, bankMoneyValue, bankMonth, bankToday, moneyCents }; +export { bankDateInputValue, bankMonth, bankToday }; diff --git a/src/entities/bank-card/model/form.schema.ts b/src/entities/bank-card/model/form.schema.ts index 3d4346b..c5aa609 100644 --- a/src/entities/bank-card/model/form.schema.ts +++ b/src/entities/bank-card/model/form.schema.ts @@ -1,9 +1,10 @@ import { z } from "zod"; import { validationMessage } from "@/shared/i18n"; +import { calendarDate } from "@/shared/lib"; -import { bankToday, moneyCents } from "./form-validation"; -const ISO_DATE_LENGTH = 10; +import { bankToday } from "./form-validation"; +import { moneyCents } from "./money"; const MAX_CARD_NAME_LENGTH = 100; const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n; const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n; @@ -34,11 +35,8 @@ const bankDateSchema = z .string() .refine( value => - /^\d{4}-\d{2}-\d{2}$/.test(value) + calendarDate.safeParse(value).success && value >= "0001-01-01" - && !Number.isNaN(Date.parse(`${value}T00:00:00Z`)) - && new Date(`${value}T00:00:00Z`).toISOString().slice(0, ISO_DATE_LENGTH) - === value && value <= bankToday(), { error: validationMessage("bank.validation.date") }, ); diff --git a/src/entities/bank-card/model/money.ts b/src/entities/bank-card/model/money.ts new file mode 100644 index 0000000..8b3db89 --- /dev/null +++ b/src/entities/bank-card/model/money.ts @@ -0,0 +1,26 @@ +const DECIMAL_PART_COUNT = 2; +const CENTS_PRECISION = 2; + +function bankMoneyValue(value: string) { + return value + .trim() + .replace(",", ".") + .replace(/^(-?)0+(?=\d)/, "$1"); +} + +function moneyCents(value: string): bigint | null { + const normalized = value.trim().replace(",", "."); + if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized)) + return null; + + const [whole, fraction = ""] = normalized + .replace("-", "") + .split(".", DECIMAL_PART_COUNT); + + return ( + BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`) + * (normalized.startsWith("-") ? -1n : 1n) + ); +} + +export { bankMoneyValue, moneyCents }; diff --git a/src/entities/bank-card/model/operation-appearance.ts b/src/entities/bank-card/model/operation-appearance.ts index 177aa02..4940bb6 100644 --- a/src/entities/bank-card/model/operation-appearance.ts +++ b/src/entities/bank-card/model/operation-appearance.ts @@ -1,6 +1,6 @@ import type { OperationIconProps } from "@wealthledger/icons"; -import type { BankOperation } from "../api/queries"; +import type { BankOperation } from "../api/types"; const categoryIcons: { appearance: OperationIconProps; diff --git a/src/entities/bank-card/model/operation-kinds.ts b/src/entities/bank-card/model/operation-kinds.ts index e849652..bac041c 100644 --- a/src/entities/bank-card/model/operation-kinds.ts +++ b/src/entities/bank-card/model/operation-kinds.ts @@ -1,4 +1,4 @@ -import type { BankOperation } from "../api/queries"; +import type { BankOperation } from "../api/types"; const bankOperationKinds = [ "expense", diff --git a/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx b/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx index 58d3070..fca519b 100644 --- a/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx +++ b/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { formatMoney } from "@/shared/lib"; -import type { BankCard } from "../../api/queries"; +import type { BankCard } from "../../api/types"; import classes from "./BankCardFace.module.css"; diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts b/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts index 69ebd46..3238e5b 100644 --- a/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts +++ b/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts @@ -1,10 +1,9 @@ -import { useFormContext } from "react-hook-form"; +import { useController, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; import type { BankCardValues } from "../../model/form.schema"; -import { useOpeningDateField } from "./useOpeningDateField"; -import { usePaymentSystemField } from "./usePaymentSystemField"; +import { bankDateField } from "../bankDateField"; function useBankCardFieldsVm() { const { @@ -14,10 +13,26 @@ function useBankCardFieldsVm() { } = useFormContext(); const { t } = useTranslation(); - const openingDate = useOpeningDateField(control); - const paymentSystem = usePaymentSystemField(control); + const { field: openingDateField } = useController({ control, name: "openingDate" }); + const { field: paymentSystemField } = useController({ control, name: "paymentSystem" }); - return { ...openingDate, ...paymentSystem, errors, register, t }; + const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({ + label: t(`bank.paymentSystems.${value}`), + value, + })); + + const onPaymentSystemChange = (value: null | string) => + paymentSystemField.onChange(value ?? ""); + + return { + errors, + onPaymentSystemChange, + openingDate: { ...openingDateField, ...bankDateField(openingDateField) }, + paymentSystemField, + paymentSystems, + register, + t, + }; } export { useBankCardFieldsVm }; diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx b/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx index 49611b1..b2cf032 100644 --- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx +++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx @@ -8,7 +8,7 @@ import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; -import type { BankCard } from "../../api/queries"; +import type { BankCard } from "../../api/types"; import { BankCardForm } from "./BankCardForm"; diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx b/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx index 493dc8d..786b221 100644 --- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx +++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx @@ -1,12 +1,12 @@ -import { Button, Group, Modal, Stack, Text } from "@mantine/core"; +import { Button, Fieldset, Group, Modal, Stack, Text } from "@mantine/core"; import { type FC } from "react"; import { FormProvider } from "react-hook-form"; import { formatDate } from "@/shared/lib"; -import { type BankCard } from "../../api/queries"; +import type { BankCard } from "../../api/types"; + import { BankFeedback } from "../BankFeedback"; -import classes from "../BankForms.module.css"; import { BankCardFields } from "./BankCardForm.view"; import { useBankCardFormVm } from "./BankCardForm.vm"; @@ -40,7 +40,7 @@ const BankCardForm: FC = (props) => { >
-
+
@@ -80,7 +80,7 @@ const BankCardForm: FC = (props) => { -
+
diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.view.tsx b/src/entities/bank-card/ui/BankCardForm/BankCardForm.view.tsx index d253e3e..fba5b35 100644 --- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.view.tsx +++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.view.tsx @@ -10,14 +10,9 @@ type BankCardFieldsProps = { archived: boolean }; const BankCardFields: FC = (props) => { const { - dateParser, errors, - onOpeningDateBlur, - onOpeningDateChange, - onOpeningDateInput, onPaymentSystemChange, - openingDateField, - openingDateValue, + openingDate, paymentSystemField, paymentSystems, register, @@ -71,22 +66,17 @@ const BankCardFields: FC = (props) => { /> diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts b/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts index 53bffc1..4add66a 100644 --- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts +++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts @@ -3,10 +3,13 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import type { BankCard } from "../../api/types"; + import { useCreateBankCard, useUpdateBankCard } from "../../api/mutations"; -import { type BankCard, useBankCard } from "../../api/queries"; -import { bankMoneyValue, bankToday } from "../../model/form-validation"; +import { useBankCard } from "../../api/queries"; +import { bankToday } from "../../model/form-validation"; import { bankCardSchema, type BankCardValues } from "../../model/form.schema"; +import { bankMoneyValue } from "../../model/money"; import { useBankFormFocus } from "../useBankFormFocus"; type BankCardFormProps = { card?: BankCard; onClose: () => void }; diff --git a/src/entities/bank-card/ui/BankCardForm/useOpeningDateField.ts b/src/entities/bank-card/ui/BankCardForm/useOpeningDateField.ts deleted file mode 100644 index d2a2177..0000000 --- a/src/entities/bank-card/ui/BankCardForm/useOpeningDateField.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ComponentProps } from "react"; -import type { Control } from "react-hook-form"; - -import { DateInput } from "@mantine/dates"; -import { useController } from "react-hook-form"; - -import { bankDateInputValue } from "../../model/form-validation"; -import { type BankCardValues, bankDateSchema } from "../../model/form.schema"; - -function useOpeningDateField(control: Control) { - const { field: openingDateField } = useController({ - control, - name: "openingDate", - }); - - const dateParser: ComponentProps["dateParser"] = value => - bankDateSchema.safeParse(bankDateInputValue(value)).success - ? bankDateInputValue(value) - : null; - - const onOpeningDateBlur: ComponentProps["onBlur"] = ( - event, - ) => { - openingDateField.onChange(bankDateInputValue(event.currentTarget.value)); - openingDateField.onBlur(); - }; - - const onOpeningDateChange = (value: null | string) => - openingDateField.onChange(value ?? ""); - - const onOpeningDateInput: ComponentProps["onInput"] = event => - openingDateField.onChange(bankDateInputValue(event.currentTarget.value)); - - const openingDateValue = bankDateSchema.safeParse(openingDateField.value) - .success - ? openingDateField.value - : undefined; - - return { - dateParser, - onOpeningDateBlur, - onOpeningDateChange, - onOpeningDateInput, - openingDateField, - openingDateValue, - }; -} - -export { useOpeningDateField }; diff --git a/src/entities/bank-card/ui/BankCardForm/usePaymentSystemField.ts b/src/entities/bank-card/ui/BankCardForm/usePaymentSystemField.ts deleted file mode 100644 index fd5548f..0000000 --- a/src/entities/bank-card/ui/BankCardForm/usePaymentSystemField.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Control } from "react-hook-form"; - -import { useController } from "react-hook-form"; -import { useTranslation } from "react-i18next"; - -import type { BankCardValues } from "../../model/form.schema"; - -function usePaymentSystemField(control: Control) { - const { t } = useTranslation(); - - const { field: paymentSystemField } = useController({ - control, - name: "paymentSystem", - }); - - const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({ - label: t(`bank.paymentSystems.${value}`), - value, - })); - - const onPaymentSystemChange = (value: null | string) => - paymentSystemField.onChange(value ?? ""); - - return { onPaymentSystemChange, paymentSystemField, paymentSystems }; -} - -export { usePaymentSystemField }; diff --git a/src/entities/bank-card/ui/BankForms.module.css b/src/entities/bank-card/ui/BankForms.module.css deleted file mode 100644 index 1f24583..0000000 --- a/src/entities/bank-card/ui/BankForms.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.fieldset { - border: 0; - padding: 0; - margin: 0; - min-width: 0; -} diff --git a/src/entities/bank-card/ui/BankPeriodPicker/BankPeriodPicker.vm.ts b/src/entities/bank-card/ui/BankPeriodPicker/BankPeriodPicker.vm.ts index 245df2f..00b3b86 100644 --- a/src/entities/bank-card/ui/BankPeriodPicker/BankPeriodPicker.vm.ts +++ b/src/entities/bank-card/ui/BankPeriodPicker/BankPeriodPicker.vm.ts @@ -24,7 +24,7 @@ function useBankPeriodPickerVm(props: BankPeriodPickerProps) { onChange(next); }; - return { onChange, onPeriodChange, t, value }; + return { onPeriodChange, t, value }; } export { useBankPeriodPickerVm }; diff --git a/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx index dc551be..4bd575f 100644 --- a/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx +++ b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx @@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next"; import { formatMoney } from "@/shared/lib"; -import type { BankSummary } from "../../api/queries"; +import type { BankSummary } from "../../api/types"; import { bankOperationAppearance } from "../../model/operation-appearance"; import classes from "./BankSummary.module.css"; @@ -78,6 +78,8 @@ const BankSummaryViewCategories: FC = (props) => { ); } + + return null; }; const BankSummaryView: FC = (props) => { diff --git a/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx b/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx index 5c7468d..28d8ffc 100644 --- a/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx +++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx @@ -8,7 +8,7 @@ import { waitFor, } from "@testing-library/react"; import { http, HttpResponse } from "msw"; -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; @@ -78,15 +78,16 @@ describe("CategoryManager", () => { name: "Переименовать", }); + assert.isDefined(renameButtons[0]); fireEvent.click(renameButtons[0]); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); fireEvent.click( await screen.findByRole("button", { name: "Перечитать запись" }), ); await waitFor(() => expect(reads).toBe(3)); - fireEvent.click( - screen.getAllByRole("button", { name: "Переименовать" })[1], - ); + const secondRename = screen.getAllByRole("button", { name: "Переименовать" })[1]; + assert.isDefined(secondRename); + fireEvent.click(secondRename); fireEvent.change(screen.getByLabelText(/Название категории/), { target: { value: "Новая вторая" }, }); diff --git a/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx b/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx index 3eb073c..3c0d205 100644 --- a/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx +++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx @@ -1,6 +1,6 @@ -import { - Badge, +import { Badge, Button, + Fieldset, Group, Modal, Stack, @@ -12,17 +12,14 @@ import { type FC } from "react"; import { FormProvider, useController, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import type { CategoryValues } from "../../model/category.schema"; + import { bankOperationAppearance } from "../../model/operation-appearance"; import { BankFeedback } from "../BankFeedback"; -import classes from "../BankForms.module.css"; -import { useCategoryManagerVm } from "./CategoryManager.vm"; - -type CategoryManagerProps = { - onClose: () => void; - onCreated?: (id: string) => void; -}; - -type CategoryValues = { name: string }; +import { + type CategoryManagerProps, + useCategoryManagerVm, +} from "./CategoryManager.vm"; const CategoryFields: FC = () => { const { t } = useTranslation(); @@ -76,7 +73,7 @@ const CategoryManager: FC = (props) => { >
-
+
{categoryRows?.map((category) => { return ( @@ -149,7 +146,7 @@ const CategoryManager: FC = (props) => { -
+
diff --git a/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts b/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts index ffedbec..f0328d7 100644 --- a/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts +++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts @@ -5,12 +5,14 @@ import { useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import type { ExpenseCategory } from "../../api/types"; + import { useCreateExpenseCategory, useUpdateExpenseCategory, } from "../../api/mutations"; -import { type ExpenseCategory, useBankCategories } from "../../api/queries"; -import { categorySchema } from "../../model/category.schema"; +import { useBankCategories } from "../../api/queries"; +import { categorySchema, type CategoryValues } from "../../model/category.schema"; import { useBankFormFocus } from "../useBankFormFocus"; type CategoryManagerProps = { @@ -18,8 +20,6 @@ type CategoryManagerProps = { onCreated?: (id: string) => void; }; -type CategoryValues = { name: string }; - function useCategoryManagerVm(props: CategoryManagerProps) { useBankFormFocus(); const [opened, setOpened] = useState(true); @@ -41,6 +41,13 @@ function useCategoryManagerVm(props: CategoryManagerProps) { const isPending = !opened || methods.formState.isSubmitting; const onRequestClose = () => !isPending && setOpened(false); + const finishEditing = () => { + editSessionRef.current++; + setEditing(undefined); + setLatest(undefined); + methods.reset({ name: "" }); + }; + const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => { void methods.handleSubmit(async (values) => { setError(undefined); @@ -59,10 +66,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) { if (result) props.onCreated?.(result.id); } - editSessionRef.current++; - setEditing(undefined); - setLatest(undefined); - methods.reset({ name: "" }); + finishEditing(); } catch (error_) { setError(error_); @@ -88,10 +92,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) { }; const onCancel = () => { - editSessionRef.current++; - setEditing(undefined); - setLatest(undefined); - methods.reset({ name: "" }); + finishEditing(); setError(undefined); }; @@ -129,4 +130,4 @@ function useCategoryManagerVm(props: CategoryManagerProps) { }; } -export { useCategoryManagerVm }; +export { type CategoryManagerProps, useCategoryManagerVm }; diff --git a/src/entities/bank-card/ui/bankDateField.ts b/src/entities/bank-card/ui/bankDateField.ts new file mode 100644 index 0000000..983be81 --- /dev/null +++ b/src/entities/bank-card/ui/bankDateField.ts @@ -0,0 +1,39 @@ +import type { DateInput } from "@mantine/dates"; +import type { ComponentProps } from "react"; + +import { bankDateInputValue } from "../model/form-validation"; +import { bankDateSchema } from "../model/form.schema"; + +type DateField = { + onBlur: () => void; + onChange: (value: string) => void; + value: string; +}; + +function bankDateField(field: DateField) { + const dateParser: ComponentProps["dateParser"] = (value) => { + const parsed = bankDateInputValue(value); + + return bankDateSchema.safeParse(parsed).success ? parsed : null; + }; + + const onBlur: ComponentProps["onBlur"] = (event) => { + field.onChange(bankDateInputValue(event.currentTarget.value)); + field.onBlur(); + }; + + const onInput: ComponentProps["onInput"] = event => + field.onChange(bankDateInputValue(event.currentTarget.value)); + + return { + dateParser, + onBlur, + onChange: (value: null | string) => field.onChange(value ?? ""), + onInput, + value: bankDateSchema.safeParse(field.value).success + ? field.value + : undefined, + }; +} + +export { bankDateField }; diff --git a/src/entities/bond/api/queries.ts b/src/entities/bond/api/queries.ts index 80d254b..6a78fa0 100644 --- a/src/entities/bond/api/queries.ts +++ b/src/entities/bond/api/queries.ts @@ -1,7 +1,15 @@ import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; +type BondCandles = NonNullable; + +type BondPosition = NonNullable; + const MIN_BOND_SEARCH_LENGTH = 2; +type BondCoupons = NonNullable; + +type BondMetrics = NonNullable; + type BondPeriod = NonNullable< NonNullable< paths["/api/v1/bonds/{uid}/candles"]["get"]["parameters"]["query"] @@ -10,35 +18,28 @@ type BondPeriod = NonNullable< const BOND_STALE_TIME_MS = 30_000; -function bondQueryOptions(uid: string) { - return { - ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}", { - params: { path: { uid } }, - }), - staleTime: BOND_STALE_TIME_MS, - }; -} - function useBond(identifier: string) { const isUid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( identifier, ); - const byUid = useResourceQuery({ - ...bondQueryOptions(identifier), - enabled: Boolean(identifier) && isUid, - }); + // eslint-disable-next-line unicorn/prefer-minimal-ternary -- Hoisting params breaks the generated union of endpoint request shapes. + const init = isUid + ? { params: { path: { uid: identifier } } } + : { params: { path: { ticker: identifier } } }; - const byTicker = useResourceQuery({ - ...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", { - params: { path: { ticker: identifier } }, - }), - enabled: Boolean(identifier) && !isUid, + const options = apiQueryClient.queryOptions( + "get", + isUid ? "/api/v1/bonds/{uid}" : "/api/v1/bonds/by-ticker/{ticker}", + init, + ); + + return useResourceQuery({ + ...options, + enabled: Boolean(identifier), staleTime: BOND_STALE_TIME_MS, }); - - return isUid ? byUid : byTicker; } function useBondCandles(uid: string, period: BondPeriod) { @@ -104,7 +105,10 @@ function useBondSearch(query: string) { } export { - type BondPeriod, + type BondCandles, + type BondCoupons, + type BondMetrics, + type BondPosition, MIN_BOND_SEARCH_LENGTH, useBond, useBondCandles, diff --git a/src/entities/bond/index.ts b/src/entities/bond/index.ts index f5e91aa..3bba34f 100644 --- a/src/entities/bond/index.ts +++ b/src/entities/bond/index.ts @@ -1,5 +1,8 @@ export { - type BondPeriod, + type BondCandles, + type BondCoupons, + type BondMetrics, + type BondPosition, MIN_BOND_SEARCH_LENGTH, useBond, useBondCandles, diff --git a/src/entities/broker-operation/api/useBrokerageOperations.ts b/src/entities/broker-operation/api/useBrokerageOperations.ts index 1eb71b3..6f0c2a4 100644 --- a/src/entities/broker-operation/api/useBrokerageOperations.ts +++ b/src/entities/broker-operation/api/useBrokerageOperations.ts @@ -68,6 +68,7 @@ function useBrokerageOperations( export { type BrokerageOperation, + type BrokerageOperationsQuery, brokerageOperationsQueryOptions, brokerageOperationsQueryParameters as brokerageOperationsQueryParams, operationCategories, diff --git a/src/entities/broker-operation/index.ts b/src/entities/broker-operation/index.ts index fda82ba..9c81877 100644 --- a/src/entities/broker-operation/index.ts +++ b/src/entities/broker-operation/index.ts @@ -5,4 +5,5 @@ export { type OperationCategory, useBrokerageOperations, } from "./api/useBrokerageOperations"; +export { useBrokerageOperationHistory } from "./model/useBrokerageOperationHistory"; export { LedgerEntryRow } from "./ui/LedgerEntryRow"; diff --git a/src/features/instrument-details/model/useInstrumentOperations.ts b/src/entities/broker-operation/model/useBrokerageOperationHistory.ts similarity index 64% rename from src/features/instrument-details/model/useInstrumentOperations.ts rename to src/entities/broker-operation/model/useBrokerageOperationHistory.ts index d67b5bf..3147b8c 100644 --- a/src/features/instrument-details/model/useInstrumentOperations.ts +++ b/src/entities/broker-operation/model/useBrokerageOperationHistory.ts @@ -1,19 +1,20 @@ -import { useQueryClient } from "@tanstack/react-query"; +import { hashKey, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; -import { - brokerageOperationsQueryOptions, - type OperationCategory, - useBrokerageOperations, -} from "@/entities/broker-operation"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; -function useInstrumentOperations( - uid: string, - filter: string, - categories: OperationCategory[], +import { + type BrokerageOperationsQuery, + brokerageOperationsQueryOptions, + useBrokerageOperations, +} from "../api/useBrokerageOperations"; + +function useBrokerageOperationHistory( + accountId: string | undefined, + firstPage: Omit, ) { - const scope = `${uid}:${filter}`; + const { queryKey } = brokerageOperationsQueryOptions(accountId, firstPage); + const scope = hashKey(queryKey); const [pagination, setPagination] = useState<{ cursors: string[]; @@ -22,13 +23,14 @@ function useInstrumentOperations( const cursors = pagination.scope === scope ? pagination.cursors : []; const client = useQueryClient(); - const firstPage = { categories, instrumentUid: uid, limit: 20 }; - const operations = useBrokerageOperations(uid ? "all" : undefined, { + const operations = useBrokerageOperations(accountId, { ...firstPage, cursor: cursors.at(-1), }); + const reset = () => setPagination({ cursors: [], scope }); + const retry = (retryScreen: () => unknown) => { if ( isApiErrorCode( @@ -36,10 +38,10 @@ function useInstrumentOperations( ApiErrorCode.InvalidOperationsCursor, ) ) { - setPagination({ cursors: [], scope }); + reset(); void client.invalidateQueries({ exact: true, - queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, + queryKey, }); } else void retryScreen(); @@ -56,8 +58,9 @@ function useInstrumentOperations( }, onPrevious: () => setPagination({ cursors: cursors.slice(0, -1), scope }), operations, + reset, retry, }; } -export { useInstrumentOperations }; +export { useBrokerageOperationHistory }; diff --git a/src/entities/broker-operation/ui/LedgerEntryRow.tsx b/src/entities/broker-operation/ui/LedgerEntryRow.tsx index 498154a..bd49a21 100644 --- a/src/entities/broker-operation/ui/LedgerEntryRow.tsx +++ b/src/entities/broker-operation/ui/LedgerEntryRow.tsx @@ -19,6 +19,7 @@ import { operationLabelKey, paymentPresentation } from "../model/display"; type LedgerEntryRowProps = { operation: BrokerageOperation; withDetails?: boolean; + wrap?: boolean; }; const LedgerEntryRow: FC = (props) => { @@ -84,6 +85,7 @@ const LedgerEntryRow: FC = (props) => { tone={payment.tone} value={payment.amount} valueLabel={t(payment.label)} + wrap={props.wrap} /> ); }; diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx b/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx deleted file mode 100644 index 74cf150..0000000 --- a/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { http, HttpResponse } from "msw"; -import { describe, expect, it, vi } from "vitest"; - -import { configureAuthClient } from "@/shared/api"; -import { createQueryWrapper } from "@/test/query"; -import { resource } from "@/test/resource"; -import { server } from "@/test/server"; - -import { useBrokerageAccounts } from "./useBrokerageAccounts"; - -describe("useBrokerageAccounts", () => { - it("loads accounts through the authenticated query seam", async () => { - configureAuthClient({ - accessToken: () => "token", - clear: vi.fn(), - refresh: () => Promise.resolve(true), - }); - server.use( - http.get("*/api/v1/brokerage-accounts", () => - HttpResponse.json( - resource([ - { - id: "account-1", - name: "Основной", - openedOn: "2024-08-13", - type: "BROKERAGE", - }, - ]), - ), - ), - ); - - const { result } = renderHook(() => useBrokerageAccounts(), { - wrapper: createQueryWrapper().wrapper, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toEqual([ - { - id: "account-1", - name: "Основной", - openedOn: "2024-08-13", - type: "BROKERAGE", - }, - ]); - }); - - it("keeps the current connection snapshot after a background refetch error", async () => { - configureAuthClient({ - accessToken: () => "token", - clear: vi.fn(), - refresh: () => Promise.resolve(true), - }); - let calls = 0; - server.use( - http.get("*/api/v1/brokerage-accounts", () => { - calls += 1; - - return calls === 1 - ? HttpResponse.json( - resource([ - { - id: "account-1", - name: "Основной", - openedOn: "2024-08-13", - type: "BROKERAGE", - }, - ]), - ) - : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); - }), - ); - - const { result } = renderHook(() => useBrokerageAccounts(), { - wrapper: createQueryWrapper().wrapper, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - void result.current.refetch(); - await waitFor(() => expect(result.current.isError).toBe(true)); - - expect(result.current.data?.[0].id).toBe("account-1"); - }); -}); diff --git a/src/entities/brokerage-account/model/display.ts b/src/entities/brokerage-account/model/display.ts index eb0d0c0..e624699 100644 --- a/src/entities/brokerage-account/model/display.ts +++ b/src/entities/brokerage-account/model/display.ts @@ -1,6 +1,7 @@ +import type { InvestmentResourceMeta } from "@/shared/api"; + import type { BrokerageAccount, - useBrokerageAccounts, } from "../api/useBrokerageAccounts"; const VISIBLE_CURRENCY_LIMIT = 2; @@ -9,11 +10,7 @@ type BrokerageAccountCard = { account: BrokerageAccount; positionCounts: null | { bonds: number; stocks: number }; - source?: NonNullable< - ReturnType["meta"] - >["dependencies"] extends (infer T)[] | undefined - ? T - : never; + source?: NonNullable[number]; summary: | { remainingCurrencyCount: number; state: "available"; totals: Money[] } | { state: "empty" } @@ -30,9 +27,7 @@ function accountTypeKey(type: BrokerageAccount["type"]) { function brokerageAccountCards( accounts: BrokerageAccount[], - dependencies: NonNullable< - ReturnType["meta"] - >["dependencies"] = [], + dependencies: InvestmentResourceMeta["dependencies"] = [], ): BrokerageAccountCard[] { return accounts.map((account) => { const source = dependencies.find( diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx index 345a18d..8d1e01b 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx @@ -5,7 +5,8 @@ import { WalletIcon } from "@wealthledger/icons"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; +import { formatDate, formatMoney } from "@/shared/lib"; +import { SourceSyncStatus } from "@/shared/ui"; import type { BrokerageAccountCard } from "../../model/display"; @@ -53,6 +54,39 @@ const BrokerageAccountSummary: FC = (props) => { ); }; +type BrokerageAccountSourceStatusProps = { + source: BrokerageAccountCard["source"]; +}; + +const BrokerageAccountSourceStatus: FC = ( + props, +) => { + const { source } = props; + const { t } = useTranslation(); + + const isSyncing + = source?.sync.state === "running" || source?.sync.state === "queued"; + + const errorText + = source?.sync.state === "failed" ? t("accounts.refreshError") : null; + + return ( + <> + + + {isSyncing && ( + + {t("accounts.refreshing")} + + )} + + ); +}; + const BrokerageAccountList: FC = (props) => { const { accounts } = props; const { t } = useTranslation(); @@ -90,24 +124,7 @@ const BrokerageAccountList: FC = (props) => { - {!!source?.lastSuccessfulAt && ( - - {t("investment.sourceTime", { - date: formatSnapshotTime(source.lastSuccessfulAt), - })} - - )} - - {(source?.sync.state === "running" - || source?.sync.state === "queued") && ( - - {t("accounts.refreshing")} - - )} - - {source?.sync.state === "failed" && {t("accounts.refreshError")}} - - {source?.freshness === "stale" && {t("investment.stale")}} +
{!!positionCounts && ( diff --git a/src/entities/brokerage-event/index.ts b/src/entities/brokerage-event/index.ts index f3b2e91..aab5711 100644 --- a/src/entities/brokerage-event/index.ts +++ b/src/entities/brokerage-event/index.ts @@ -2,6 +2,7 @@ export { type BrokerageEvent, useBrokerageEvents, } from "./api/useBrokerageEvents"; +export { brokerageEventKey } from "./model/event-key"; export { BrokerageEventCard } from "./ui/BrokerageEventCard"; export { BrokerageEventsPreview, diff --git a/src/entities/brokerage-event/model/event-key.ts b/src/entities/brokerage-event/model/event-key.ts new file mode 100644 index 0000000..1d460bd --- /dev/null +++ b/src/entities/brokerage-event/model/event-key.ts @@ -0,0 +1,10 @@ +import type { BrokerageEvent } from "../api/useBrokerageEvents"; + +function brokerageEventKey(event: BrokerageEvent) { + return JSON.stringify([ + event.account?.id, event.type, event.date, event.instrumentUid, + event.figi, event.ticker, event.logoName, + ]); +} + +export { brokerageEventKey }; diff --git a/src/entities/brokerage-event/ui/BrokerageEventCard.tsx b/src/entities/brokerage-event/ui/BrokerageEventCard.tsx index 31cb0b8..c372026 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventCard.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventCard.tsx @@ -34,7 +34,7 @@ const BrokerageEventCard: FC = (props) => { = (props) => { const { events } = props; const { t } = useTranslation(); + const errorText + = events.state === "loaded" && events.refreshError + ? t("accounts.refreshError") + : null; + return ( {events.state === "loaded" && ( - <> - {!!events.lastSuccessfulAt && ( - - {t("investment.sourceTime", { - date: formatSnapshotTime(events.lastSuccessfulAt), - })} - - )} - - {!!events.stale && {t("investment.stale")}} - - {!!events.refreshError && {t("accounts.refreshError")}} - + )} {events.state === "loading" && ( @@ -66,15 +64,7 @@ const BrokerageEventsPreview: FC = (props) => { && events.items.map(event => ( ))} diff --git a/src/entities/income/api/useIncome.ts b/src/entities/income/api/useIncome.ts index a6357e8..449eef3 100644 --- a/src/entities/income/api/useIncome.ts +++ b/src/entities/income/api/useIncome.ts @@ -4,6 +4,9 @@ type IncomeAnalytics = NonNullable< paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"] >; +type IncomeAnalyticsMeta + = paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["meta"]; + type IncomeSummary = NonNullable< paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"] >; @@ -44,6 +47,7 @@ function useIncomeSummary(accountId?: string) { export { type IncomeAnalytics, + type IncomeAnalyticsMeta, type IncomeSummary, useIncomeAnalytics, useIncomeSummary, diff --git a/src/entities/income/index.ts b/src/entities/income/index.ts index a277e6d..7ecf287 100644 --- a/src/entities/income/index.ts +++ b/src/entities/income/index.ts @@ -1,5 +1,6 @@ export { type IncomeAnalytics, + type IncomeAnalyticsMeta, type IncomeSummary, useIncomeAnalytics, useIncomeSummary, diff --git a/src/entities/portfolio/api/usePortfolio.test.tsx b/src/entities/portfolio/api/usePortfolio.test.tsx deleted file mode 100644 index 87733c5..0000000 --- a/src/entities/portfolio/api/usePortfolio.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { http, HttpResponse } from "msw"; -import { describe, expect, it, vi } from "vitest"; - -import { configureAuthClient } from "@/shared/api"; -import { createQueryWrapper } from "@/test/query"; -import { resource } from "@/test/resource"; -import { server } from "@/test/server"; - -import { usePortfolio } from "./usePortfolio"; - -describe("usePortfolio", () => { - it("loads the account portfolio", async () => { - configureAuthClient({ - accessToken: () => "token", - clear: vi.fn(), - refresh: () => Promise.resolve(true), - }); - - const { result } = renderHook(() => usePortfolio("account-1"), { - wrapper: createQueryWrapper().wrapper, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toMatchObject({ positions: [] }); - }); - - it("keeps the current connection snapshot after a background refetch error", async () => { - configureAuthClient({ - accessToken: () => "token", - clear: vi.fn(), - refresh: () => Promise.resolve(true), - }); - let calls = 0; - server.use( - http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { - calls += 1; - - return calls === 1 - ? HttpResponse.json( - resource({ - cashBalances: [], - isStale: false, - lastSuccessfulAt: "2026-08-14T00:00:00Z", - positions: [], - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [], - }), - ) - : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); - }), - ); - - const { result } = renderHook(() => usePortfolio("account-1"), { - wrapper: createQueryWrapper().wrapper, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - void result.current.refetch(); - await waitFor(() => expect(result.current.isError).toBe(true)); - - expect(result.current.data).toMatchObject({ - lastSuccessfulAt: "2026-08-14T00:00:00Z", - }); - }); -}); diff --git a/src/entities/stock/api/queries.ts b/src/entities/stock/api/queries.ts index 969158e..8afef1e 100644 --- a/src/entities/stock/api/queries.ts +++ b/src/entities/stock/api/queries.ts @@ -1,13 +1,17 @@ import { apiQueryClient, type paths, - unwrapResourceResult, - useAuthenticatedQueries, useResourceQuery, } from "@/shared/api"; +type StockCandles = NonNullable; + +type StockPosition = NonNullable; + const MIN_STOCK_SEARCH_LENGTH = 2; +type StockDividends = NonNullable; + type StockPeriod = NonNullable< NonNullable< paths["/api/v1/stocks/{uid}/candles"]["get"]["parameters"]["query"] @@ -16,35 +20,28 @@ type StockPeriod = NonNullable< const STOCK_STALE_TIME_MS = 30_000; -function stockQueryOptions(uid: string) { - return { - ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}", { - params: { path: { uid } }, - }), - staleTime: STOCK_STALE_TIME_MS, - }; -} - function useStock(identifier: string) { const isUid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( identifier, ); - const byUid = useResourceQuery({ - ...stockQueryOptions(identifier), - enabled: Boolean(identifier) && isUid, - }); + // eslint-disable-next-line unicorn/prefer-minimal-ternary -- Hoisting params breaks the generated union of endpoint request shapes. + const init = isUid + ? { params: { path: { uid: identifier } } } + : { params: { path: { ticker: identifier } } }; - const byTicker = useResourceQuery({ - ...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", { - params: { path: { ticker: identifier } }, - }), - enabled: Boolean(identifier) && !isUid, + const options = apiQueryClient.queryOptions( + "get", + isUid ? "/api/v1/stocks/{uid}" : "/api/v1/stocks/by-ticker/{ticker}", + init, + ); + + return useResourceQuery({ + ...options, + enabled: Boolean(identifier), staleTime: STOCK_STALE_TIME_MS, }); - - return isUid ? byUid : byTicker; } function useStockCandles(uid: string, period: StockPeriod) { @@ -99,22 +96,6 @@ function useStockQuote(uid: string) { }); } -function useStocks(uids: string[]) { - const uniqueUids = [...new Set(uids)]; - - const queries = useAuthenticatedQueries( - uniqueUids.map(uniqueUid => stockQueryOptions(uniqueUid)), - ); - - return new Map( - queries.map((query, index) => [ - // eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. - uniqueUids[index], - unwrapResourceResult(query).data, - ]), - ); -} - function useStockSearch(query: string) { return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/stocks", { @@ -127,13 +108,14 @@ function useStockSearch(query: string) { export { MIN_STOCK_SEARCH_LENGTH, - type StockPeriod, + type StockCandles, + type StockDividends, + type StockPosition, useStock, useStockCandles, useStockCompany, useStockDividends, useStockPosition, useStockQuote, - useStocks, useStockSearch, }; diff --git a/src/entities/stock/index.ts b/src/entities/stock/index.ts index f3018b2..b349d42 100644 --- a/src/entities/stock/index.ts +++ b/src/entities/stock/index.ts @@ -1,12 +1,13 @@ export { MIN_STOCK_SEARCH_LENGTH, - type StockPeriod, + type StockCandles, + type StockDividends, + type StockPosition, useStock, useStockCandles, useStockCompany, useStockDividends, useStockPosition, useStockQuote, - useStocks, useStockSearch, } from "./api/queries"; diff --git a/src/features/auth/model/auth-context.ts b/src/features/auth/model/auth-context.ts index 083e5b2..1a0ed08 100644 --- a/src/features/auth/model/auth-context.ts +++ b/src/features/auth/model/auth-context.ts @@ -3,12 +3,11 @@ import { createContext, use } from "react"; import type { paths } from "@/shared/api"; type AuthContextValue = { - accessToken: string | undefined; login: (email: string, password: string) => Promise; logout: () => Promise; ready: boolean; - refresh: () => Promise; register: (email: string, password: string) => Promise; + signedOut: boolean; user: undefined | User; }; diff --git a/src/features/auth/model/auth.test.tsx b/src/features/auth/model/auth.test.tsx index 6d2fb65..ce7bb17 100644 --- a/src/features/auth/model/auth.test.tsx +++ b/src/features/auth/model/auth.test.tsx @@ -27,6 +27,32 @@ function createAuthWrapper( ); } +async function expireSession() { + server.use(http.get("*/api/v1/brokerage-accounts", () => + HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }), + { once: true })); + + try { + await apiClient.GET("/api/v1/brokerage-accounts"); + } + catch (error) { + // A refresh that changes identity cancels the original user's request. + if (!(error instanceof DOMException && error.name === "AbortError")) throw error; + } +} + +async function requestAuthorization() { + let authorization: null | string = null; + server.use(http.get("*/api/v1/brokerage-accounts", ({ request }) => { + authorization = request.headers.get("Authorization"); + + return HttpResponse.json(resource([])); + }, { once: true })); + await apiClient.GET("/api/v1/brokerage-accounts"); + + return authorization; +} + describe("AuthProvider", () => { it("authorizes requests after restoring the initial session", async () => { let authorization: null | string @@ -77,7 +103,7 @@ describe("AuthProvider", () => { await waitFor(() => expect(result.current.ready).toBe(true)); expect(result.current.user).toBeUndefined(); - expect(result.current.accessToken).toBeUndefined(); + expect(await requestAuthorization()).toBeNull(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); }); @@ -106,13 +132,13 @@ describe("AuthProvider", () => { const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => - expect(result.current.accessToken).toBe("restored-token"), + expect(result.current.ready).toBe(true), ); const response = await apiClient.GET("/api/v1/brokerage-accounts"); expect(response.response.status).toBe(401); await waitFor(() => expect(result.current.user).toBeUndefined()); - expect(result.current.accessToken).toBeUndefined(); + expect(await requestAuthorization()).toBeNull(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); }); @@ -147,7 +173,7 @@ describe("AuthProvider", () => { queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); const wrapper: FC = createAuthWrapper(queryClient); const { result } = renderHook(() => useAuth(), { wrapper }); - await waitFor(() => expect(result.current.accessToken).toBe("test-token")); + await waitFor(() => expect(result.current.ready).toBe(true)); expect(queryClient.getQueryData(bankCardsKey)).toEqual([ { id: "bank-card-1" }, @@ -155,7 +181,7 @@ describe("AuthProvider", () => { await act(() => result.current.logout()); expect(result.current.user).toBeUndefined(); - expect(result.current.accessToken).toBeUndefined(); + expect(await requestAuthorization()).toBeNull(); expect(queryClient.getQueryData(statusKey)).toBeUndefined(); expect(queryClient.getQueryData(portfolioKey)).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); @@ -265,7 +291,7 @@ describe("AuthProvider", () => { const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.ready).toBe(true)); queryClient.setQueryData(bankCardsKey, [{ id: "a-card" }]); - await act(() => result.current.refresh()); + await act(() => expireSession()); expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]); let release!: (value: string) => void; @@ -283,7 +309,7 @@ describe("AuthProvider", () => { .catch(() => {}); identity = "b"; - await act(() => result.current.refresh()); + await act(() => expireSession()); release("a-data"); await pending; expect(result.current.user?.id).toBe("b"); @@ -319,12 +345,12 @@ describe("AuthProvider", () => { ); await waitFor(() => - expect(result.current.cards.data?.[0].id).toBe("a-card"), + expect(result.current.cards.data?.[0]?.id).toBe("a-card"), ); identity = "b"; - await act(() => result.current.auth.refresh()); + await act(() => expireSession()); await waitFor(() => - expect(result.current.cards.data?.[0].id).toBe("b-card"), + expect(result.current.cards.data?.[0]?.id).toBe("b-card"), ); }); }); diff --git a/src/features/auth/model/auth.vm.ts b/src/features/auth/model/auth.vm.ts index cb757bd..191d160 100644 --- a/src/features/auth/model/auth.vm.ts +++ b/src/features/auth/model/auth.vm.ts @@ -29,9 +29,9 @@ async function request( function useAuthProviderVm() { const queryClient = useQueryClient(); const [user, setUser] = useState(undefined); - const [accessToken, setAccessToken] = useState(undefined); const accessTokenRef = useRef(undefined); const [ready, setReady] = useState(false); + const [signedOut, setSignedOut] = useState(false); const generationRef = useRef(0); const userRef = useRef(undefined); @@ -58,6 +58,7 @@ function useAuthProviderVm() { const changedIdentity = userRef.current && userRef.current.id !== authentication.user.id; + setSignedOut(false); accessTokenRef.current = authentication.accessToken; if (changedIdentity) { @@ -69,7 +70,6 @@ function useAuthProviderVm() { userRef.current = authentication.user; setUser(authentication.user); - setAccessToken(authentication.accessToken); }, [queryClient], ); @@ -78,7 +78,6 @@ function useAuthProviderVm() { generationRef.current += 1; userRef.current = undefined; setUser(undefined); - setAccessToken(undefined); accessTokenRef.current = undefined; queryClient.clear(); }, [queryClient]); @@ -139,6 +138,7 @@ function useAuthProviderVm() { } async function logout() { + setSignedOut(true); clearSession(); await serialize(async () => { await apiClient.POST("/api/v1/auth/logout"); @@ -156,14 +156,13 @@ function useAuthProviderVm() { useEffect(() => () => configureAuthClient(), []); const authContextValue = { - accessToken, login: (email: string, password: string) => authenticate("/api/v1/auth/login", email, password), logout, ready, - refresh, register: (email: string, password: string) => authenticate("/api/v1/auth/register", email, password), + signedOut, user, }; diff --git a/src/features/auth/ui/AuthForm/AuthForm.module.css b/src/features/auth/ui/AuthForm/AuthForm.module.css deleted file mode 100644 index 659c819..0000000 --- a/src/features/auth/ui/AuthForm/AuthForm.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.fieldset { - border: 0; - padding: 0; -} diff --git a/src/features/auth/ui/AuthForm/AuthForm.tsx b/src/features/auth/ui/AuthForm/AuthForm.tsx index 5e90388..3149dab 100644 --- a/src/features/auth/ui/AuthForm/AuthForm.tsx +++ b/src/features/auth/ui/AuthForm/AuthForm.tsx @@ -1,9 +1,9 @@ import type { FC } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core"; +import { Alert, Button, Fieldset, PasswordInput, Stack, TextInput } from "@mantine/core"; import { useState } from "react"; -import { FormProvider, type Resolver, useForm } from "react-hook-form"; +import { FormProvider, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; @@ -12,7 +12,6 @@ import { loginSchema, registerSchema, } from "../../model/auth.schema"; -import classes from "./AuthForm.module.css"; type AuthFormProps = { onSubmit: (email: string, password: string) => Promise; @@ -25,7 +24,7 @@ const AuthForm: FC = (props) => { const schema = register ? registerSchema : loginSchema; const methods = useForm({ - resolver: zodResolver(schema) as Resolver, + resolver: zodResolver(schema), }); const { @@ -34,7 +33,7 @@ const AuthForm: FC = (props) => { const [submitError, setSubmitError] = useState(null); - const onSubmit2 = methods.handleSubmit(async (values) => { + const submit = methods.handleSubmit(async (values) => { setSubmitError(null); try { @@ -49,26 +48,26 @@ const AuthForm: FC = (props) => { return ( -
-
+ +
{register && ( )} @@ -82,7 +81,7 @@ const AuthForm: FC = (props) => { {t(register ? "auth.haveAccount" : "auth.noAccount")} -
+
); diff --git a/src/features/auth/ui/LoginForm/LoginForm.tsx b/src/features/auth/ui/LoginForm/LoginForm.tsx index d3a6cea..d9696c2 100644 --- a/src/features/auth/ui/LoginForm/LoginForm.tsx +++ b/src/features/auth/ui/LoginForm/LoginForm.tsx @@ -1,12 +1,12 @@ import type { FC } from "react"; +import { useAuth } from "../../model/auth-context"; import { AuthForm } from "../AuthForm/AuthForm"; -import { useLoginFormVm } from "./LoginForm.vm"; const LoginForm: FC = () => { - const { onSubmit } = useLoginFormVm(); + const { login } = useAuth(); - return ; + return ; }; export { LoginForm }; diff --git a/src/features/auth/ui/LoginForm/LoginForm.vm.ts b/src/features/auth/ui/LoginForm/LoginForm.vm.ts deleted file mode 100644 index 0392611..0000000 --- a/src/features/auth/ui/LoginForm/LoginForm.vm.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ComponentProps } from "react"; - -import { useLocation, useNavigate } from "react-router"; - -import { useAuth } from "../../model/auth-context"; -import { AuthForm } from "../AuthForm/AuthForm"; - -function useLoginFormVm() { - const { login } = useAuth(); - const navigate = useNavigate(); - const location = useLocation(); - - const returnTo - = (location.state as null - | { returnTo?: string })?.returnTo ?? "/accounts"; - - const onSubmit: ComponentProps["onSubmit"] = async ( - email, - password, - ) => { - await login(email, password); - await navigate(returnTo, { replace: true }); - }; - - return { onSubmit }; -} - -export { useLoginFormVm }; diff --git a/src/features/auth/ui/RegisterForm/RegisterForm.tsx b/src/features/auth/ui/RegisterForm/RegisterForm.tsx index 9832dd1..f293501 100644 --- a/src/features/auth/ui/RegisterForm/RegisterForm.tsx +++ b/src/features/auth/ui/RegisterForm/RegisterForm.tsx @@ -1,12 +1,12 @@ import type { FC } from "react"; +import { useAuth } from "../../model/auth-context"; import { AuthForm } from "../AuthForm/AuthForm"; -import { useRegisterFormVm } from "./RegisterForm.vm"; const RegisterForm: FC = () => { - const { onSubmit } = useRegisterFormVm(); + const { register } = useAuth(); - return ; + return ; }; export { RegisterForm }; diff --git a/src/features/auth/ui/RegisterForm/RegisterForm.vm.ts b/src/features/auth/ui/RegisterForm/RegisterForm.vm.ts deleted file mode 100644 index 35a3235..0000000 --- a/src/features/auth/ui/RegisterForm/RegisterForm.vm.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ComponentProps } from "react"; - -import { useLocation, useNavigate } from "react-router"; - -import { useAuth } from "../../model/auth-context"; -import { AuthForm } from "../AuthForm/AuthForm"; - -function useRegisterFormVm() { - const { register } = useAuth(); - const navigate = useNavigate(); - const location = useLocation(); - - const returnTo - = (location.state as null - | { returnTo?: string })?.returnTo ?? "/accounts"; - - const onSubmit: ComponentProps["onSubmit"] = async ( - email, - password, - ) => { - await register(email, password); - await navigate(returnTo, { replace: true }); - }; - - return { onSubmit, register }; -} - -export { useRegisterFormVm }; diff --git a/src/features/instrument-details/index.ts b/src/features/instrument-details/index.ts index 2cd23d8..1ee5023 100644 --- a/src/features/instrument-details/index.ts +++ b/src/features/instrument-details/index.ts @@ -1,6 +1,17 @@ -export { section } from "./model/section"; -export { useInstrumentOperations } from "./model/useInstrumentOperations"; +export { toInstrumentEventRows } from "./model/operation-rows"; +export { section, type SectionState } from "./model/section"; +export { useInstrumentParameters } from "./model/useInstrumentParameters"; export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view"; export { InstrumentEvents } from "./ui/InstrumentEvents.view"; +export { InstrumentHeader } from "./ui/InstrumentHeader/InstrumentHeader.view"; +export { + InstrumentMetrics, + InstrumentPage, + InstrumentPrice, + InstrumentTerms, +} from "./ui/InstrumentLayout.view"; +export { InstrumentMetric } from "./ui/InstrumentMetric/InstrumentMetric.view"; +export { InstrumentPageState } from "./ui/InstrumentPageState.view"; export { InstrumentPosition } from "./ui/InstrumentPosition.view"; +export { InstrumentQuoteStatus } from "./ui/InstrumentQuoteStatus.view"; export { InstrumentSection } from "./ui/InstrumentSection.view"; diff --git a/src/features/instrument-details/model/operation-rows.test.ts b/src/features/instrument-details/model/operation-rows.test.ts new file mode 100644 index 0000000..d3bbe27 --- /dev/null +++ b/src/features/instrument-details/model/operation-rows.test.ts @@ -0,0 +1,44 @@ +import { expect, it } from "vitest"; + +import type { BrokerageOperation } from "@/entities/broker-operation"; + +import { toInstrumentEventRows } from "./operation-rows"; + +const operation: BrokerageOperation = { + accruedInterest: null, + category: "trade", + commission: null, + description: "Покупка", + figi: null, + id: "first", + instrumentType: "stock", + instrumentUid: "uid", + logoName: null, + occurredAt: "2026-01-01T00:00:00Z", + payment: null, + price: null, + quantity: null, + ticker: null, + type: "OPERATION_TYPE_BUY", +}; + +it("fills only missing identity without changing rows or their order", () => { + const items = [operation, { + ...operation, + id: "second", + logoName: "", + ticker: "own", + }]; + + const original = structuredClone(items); + expect(toInstrumentEventRows(items, { + logoName: "fallback.png", + ticker: "fallback", + })).toEqual([ + { ...operation, logoName: "fallback.png", ticker: "fallback" }, + items[1], + ]); + expect(items).toEqual(original); + expect(toInstrumentEventRows(items)).toEqual(items); + expect(toInstrumentEventRows()).toEqual([]); +}); diff --git a/src/features/instrument-details/model/operation-rows.ts b/src/features/instrument-details/model/operation-rows.ts new file mode 100644 index 0000000..905a494 --- /dev/null +++ b/src/features/instrument-details/model/operation-rows.ts @@ -0,0 +1,14 @@ +import type { BrokerageOperation } from "@/entities/broker-operation"; + +function toInstrumentEventRows( + items: BrokerageOperation[] = [], + instrument?: Pick, +): BrokerageOperation[] { + return items.map(item => ({ + ...item, + logoName: item.logoName ?? instrument?.logoName ?? null, + ticker: item.ticker ?? instrument?.ticker ?? null, + })); +} + +export { toInstrumentEventRows }; diff --git a/src/features/instrument-details/model/section.ts b/src/features/instrument-details/model/section.ts index 386ac17..d0d5d17 100644 --- a/src/features/instrument-details/model/section.ts +++ b/src/features/instrument-details/model/section.ts @@ -1,7 +1,4 @@ -import type { paths } from "@/shared/api"; - -type Meta - = paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number]; +import type { InvestmentResourceMeta } from "@/shared/api"; type SectionState = { error: boolean; @@ -20,7 +17,7 @@ function section( data: unknown; isError: boolean; isFetching: boolean; - meta?: Meta; + meta?: InvestmentResourceMeta; refetch: () => unknown; }, retry?: () => unknown, diff --git a/src/features/instrument-details/model/types.ts b/src/features/instrument-details/model/types.ts index 9ff7ecb..acab9ad 100644 --- a/src/features/instrument-details/model/types.ts +++ b/src/features/instrument-details/model/types.ts @@ -1,23 +1,22 @@ +import type { BondCandles, BondPosition } from "@/entities/bond"; import type { BrokerageOperation } from "@/entities/broker-operation"; -import type { paths } from "@/shared/api"; +import type { StockCandles, StockPosition } from "@/entities/stock"; import type { SectionState } from "./section"; -type Candles = NonNullable< - paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"] +type Candles = Pick; -type ChartRow = Candles["candles"][number] & { date: string; price: number }; +type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm }; type InstrumentChartVm = { - averageLine: null | number; candles?: Candles; candlesState: SectionState; - chartData: ChartRow[]; - hasIncompleteCandle: boolean; period: string; position?: Position; - priceRows: ChartRow[]; setPeriod: (value: string) => void; }; @@ -37,8 +36,6 @@ type InstrumentPositionVm = { positionState: SectionState; }; -type Position = NonNullable< - paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"] ->; +type Position = BondPosition | StockPosition; -export type { InstrumentChartVm, InstrumentEventsVm, InstrumentPositionVm }; +export type { InstrumentChartProps, InstrumentEventsVm, InstrumentPositionVm }; diff --git a/src/features/instrument-details/model/useInstrumentParameters.ts b/src/features/instrument-details/model/useInstrumentParameters.ts new file mode 100644 index 0000000..6470047 --- /dev/null +++ b/src/features/instrument-details/model/useInstrumentParameters.ts @@ -0,0 +1,43 @@ +import { useSearchParams } from "react-router"; + +import type { OperationCategory } from "@/entities/broker-operation"; + +const instrumentPeriods = ["month", "halfYear", "year", "all"] as const; + +function useInstrumentParameters(incomeFilter: "coupons" | "dividends") { + const [parameters, setParameters] = useSearchParams(); + + const period = instrumentPeriods.find( + value => value === parameters.get("period"), + ) ?? "month"; + + const defaultFilter: { categories: OperationCategory[]; value: string } = { categories: [], value: "all" }; + + const filters = [ + defaultFilter, + { categories: ["trade"], value: "trades" }, + { categories: ["income"], value: incomeFilter }, + { categories: ["tax", "fee", "transfer", "other"], value: "other" }, + ] satisfies { categories: OperationCategory[]; value: string }[]; + + const { categories, value: filter } = filters.find( + item => item.value === parameters.get("events"), + ) ?? defaultFilter; + + const setParameter = (key: string, value: string) => { + const next = new URLSearchParams(parameters); + next.set(key, value); + setParameters(next, { replace: true }); + }; + + return { + categories, + filter, + period, + search: parameters.size > 0 ? `?${parameters.toString()}` : "", + setFilter: (value: string) => setParameter("events", value), + setPeriod: (value: string) => setParameter("period", value), + }; +} + +export { instrumentPeriods, useInstrumentParameters }; diff --git a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.view.tsx b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.view.tsx index 7aa826c..88cf2d7 100644 --- a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.view.tsx +++ b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.view.tsx @@ -10,20 +10,19 @@ import { Text, } from "@mantine/core"; -import { formatMoney } from "@/shared/lib"; +import { formatChartValue, formatMoney } from "@/shared/lib"; -import type { InstrumentChartVm } from "../../model/types"; +import type { InstrumentChartProps } from "../../model/types"; import classes from "../InstrumentDetails.module.css"; import { InstrumentSection } from "../InstrumentSection.view"; import { useInstrumentChartVm } from "./InstrumentChart.vm"; -type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm }; - const InstrumentChart: FC = (props) => { const { + chartData, chartLabel, - formatChartValue, + hasIncompleteCandle, periodOptions, priceRows, referenceLines, @@ -34,14 +33,14 @@ const InstrumentChart: FC = (props) => { let chartContent; if (vm.candles) { - if (vm.chartData.length > 0) { + if (chartData.length > 0) { chartContent = ( <> = (props) => { tickLine="none" tooltipAnimationDuration={0} valueFormatter={formatChartValue} - withDots={vm.chartData.length === 1} + withDots={chartData.length === 1} withGradient yAxisProps={{ domain: ["auto", "auto"], width: 64 }} /> @@ -65,13 +64,13 @@ const InstrumentChart: FC = (props) => { )} - {!!vm.hasIncompleteCandle && ( + {!!hasIncompleteCandle && ( {t("stock.incomplete")} )} - {vm.averageLine !== null && ( + {referenceLines.length > 0 && ( {t("stock.averageLine")}:{" "} {formatMoney(vm.position?.averagePurchasePrice)} @@ -88,7 +87,7 @@ const InstrumentChart: FC = (props) => { {t("stock.priceTable")} - {t("stock.priceTableHint", { count: vm.chartData.length })} + {t("stock.priceTableHint", { count: chartData.length })} diff --git a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts index 02c6d0c..8cbc580 100644 --- a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts +++ b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts @@ -1,16 +1,29 @@ import { useTranslation } from "react-i18next"; -import { formatChartValue, formatDecimal, formatMoney } from "@/shared/lib"; +import { formatDate, formatDecimal, formatMoney } from "@/shared/lib"; -import type { InstrumentChartVm } from "../../model/types"; +import type { InstrumentChartProps } from "../../model/types"; -type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm }; +import { instrumentPeriods } from "../../model/useInstrumentParameters"; function useInstrumentChartVm(props: InstrumentChartProps) { const { vm } = props; const { t } = useTranslation(); - const priceRows = vm.priceRows.map((candle) => { + const chartData = vm.candles?.candles.map(candle => ({ + ...candle, + date: formatDate(candle.time), + price: Number(candle.close), + })) ?? []; + + const average = vm.position?.averagePurchasePrice; + + const averageLine = average && props.priceUnit !== "percent" + && average.currency === vm.candles?.currency + ? Number(average.value) + : null; + + const priceRows = chartData.toReversed().map((candle) => { const currency = vm.candles?.currency; const value = candle.close; @@ -23,7 +36,7 @@ function useInstrumentChartVm(props: InstrumentChartProps) { return { ...candle, price }; }); - const periodOptions = ["month", "halfYear", "year", "all"].map(value => ({ + const periodOptions = instrumentPeriods.map(value => ({ label: t(`stock.periods.${value}`), value, })); @@ -39,20 +52,21 @@ function useInstrumentChartVm(props: InstrumentChartProps) { ); const referenceLines - = vm.averageLine === null + = averageLine === null || !Number.isFinite(averageLine) ? [] : [ { color: "orange.7", ifOverflow: "extendDomain" as const, label: t("stock.averageLine"), - y: vm.averageLine, + y: averageLine, }, ]; return { + chartData, chartLabel, - formatChartValue, + hasIncompleteCandle: chartData.some(candle => !candle.isComplete), periodOptions, priceRows, referenceLines, diff --git a/src/features/instrument-details/ui/InstrumentDetails.module.css b/src/features/instrument-details/ui/InstrumentDetails.module.css index 5947dc0..9d86734 100644 --- a/src/features/instrument-details/ui/InstrumentDetails.module.css +++ b/src/features/instrument-details/ui/InstrumentDetails.module.css @@ -60,3 +60,31 @@ grid-column: 1 / -1; } } + +.page { + max-width: 1120px; + margin: 0 auto; + min-width: 0; +} +.price { + font-size: clamp(2rem, 4vw, 3.2rem); + font-weight: 700; + letter-spacing: -0.04em; + font-variant-numeric: tabular-nums; + line-height: 1.2; + overflow-wrap: anywhere; +} +.metricsGrid { + --instrument-metric-padding: var(--mantine-spacing-sm); + --instrument-metric-background: var(--wl-color-subtle); + --instrument-metric-radius: var(--mantine-radius-md); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); + gap: var(--mantine-spacing-lg); +} + +@media (max-width: 48em) { + .metricsGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.module.css b/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.module.css new file mode 100644 index 0000000..0ee88f3 --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.module.css @@ -0,0 +1,17 @@ +.header { + padding: var(--mantine-spacing-lg); + border-radius: var(--mantine-radius-lg); + min-width: 0; + overflow-wrap: anywhere; +} +.identity { + display: flex; + align-items: center; + gap: var(--mantine-spacing-md); + min-width: 0; +} +@media (max-width: 48em) { + .header { + padding: var(--mantine-spacing-md); + } +} diff --git a/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.view.tsx b/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.view.tsx new file mode 100644 index 0000000..8a260e1 --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.view.tsx @@ -0,0 +1,45 @@ +import type { FC, PropsWithChildren, ReactNode } from "react"; + +import { Avatar, Button, Group, Paper, Text, Title } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +import classes from "./InstrumentHeader.module.css"; + +type InstrumentHeaderProps = PropsWithChildren<{ + logoSrc: null | string; + name: string; + onRefresh: () => void; + refreshing: boolean; + subtitle: ReactNode; +}>; + +const InstrumentHeader: FC = (props) => { + const { children, logoSrc, name, onRefresh, refreshing, subtitle } = props; + const { t } = useTranslation(); + + return ( + + +
+ + {name[0]} + + +
+ {name} + + {subtitle} +
+
+ + +
+ + {children} +
+ ); +}; + +export { InstrumentHeader }; diff --git a/src/features/instrument-details/ui/InstrumentLayout.view.tsx b/src/features/instrument-details/ui/InstrumentLayout.view.tsx new file mode 100644 index 0000000..c6d2b04 --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentLayout.view.tsx @@ -0,0 +1,31 @@ +import type { FC, PropsWithChildren } from "react"; + +import { Stack, Text, type TextProps, Title } from "@mantine/core"; + +import classes from "./InstrumentDetails.module.css"; + +type InstrumentPriceProps = PropsWithChildren>; + +type InstrumentTermsProps = PropsWithChildren<{ title: string }>; + +const InstrumentPage: FC = props => ( + {props.children} +); + +const InstrumentPrice: FC = props => ( + {props.children} +); + +const InstrumentMetrics: FC = props => ( +
{props.children}
+); + +const InstrumentTerms: FC = props => ( +
+ {props.title} + + {props.children} +
+); + +export { InstrumentMetrics, InstrumentPage, InstrumentPrice, InstrumentTerms }; diff --git a/src/features/instrument-details/ui/InstrumentMetric/InstrumentMetric.module.css b/src/features/instrument-details/ui/InstrumentMetric/InstrumentMetric.module.css new file mode 100644 index 0000000..3d673f0 --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentMetric/InstrumentMetric.module.css @@ -0,0 +1,5 @@ +.metric { + padding: var(--instrument-metric-padding, 0); + background: var(--instrument-metric-background, transparent); + border-radius: var(--instrument-metric-radius, 0); +} diff --git a/src/features/instrument-details/ui/InstrumentMetric/InstrumentMetric.view.tsx b/src/features/instrument-details/ui/InstrumentMetric/InstrumentMetric.view.tsx new file mode 100644 index 0000000..c57e56f --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentMetric/InstrumentMetric.view.tsx @@ -0,0 +1,21 @@ +import type { FC } from "react"; + +import { Text } from "@mantine/core"; + +import classes from "./InstrumentMetric.module.css"; + +type InstrumentMetricProps = { label: string; value: string }; + +const InstrumentMetric: FC = props => ( +
+ + {props.label} + + + + {props.value} + +
+); + +export { InstrumentMetric }; diff --git a/src/features/instrument-details/ui/InstrumentPageState.view.tsx b/src/features/instrument-details/ui/InstrumentPageState.view.tsx new file mode 100644 index 0000000..aa6fb61 --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentPageState.view.tsx @@ -0,0 +1,66 @@ +import type { FC } from "react"; + +import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; + +import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; + +type InstrumentPageStateProps = { + code?: string; + kind: "bond" | "stock"; + loading: boolean; + retry: () => void; +}; + +const InstrumentPageState: FC = (props) => { + const { code, kind, loading, retry } = props; + const { t } = useTranslation(); + if (loading) return ( + + + + + + + + + + ); + + const ambiguousCode = kind === "stock" + ? ApiErrorCode.StockTickerAmbiguous + : ApiErrorCode.BondTickerAmbiguous; + + let message = t(`${kind}.unavailableText`); + if (isApiErrorCode(code, ambiguousCode)) + message = t(`${kind}.ambiguousTicker`); + if ( + isApiErrorCode(code, ApiErrorCode.TbankNotConnected) + || isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) + ) + message = t(`${kind}.connect`); + + return ( + + {t(`${kind}.unavailable`)} + + {message} + + + {t(`${kind}.backToSearch`)} + + + + {t("accounts.openSettings")} + + + + + ); +}; + +export { InstrumentPageState }; diff --git a/src/features/instrument-details/ui/InstrumentQuoteStatus.view.tsx b/src/features/instrument-details/ui/InstrumentQuoteStatus.view.tsx new file mode 100644 index 0000000..0c8cca2 --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentQuoteStatus.view.tsx @@ -0,0 +1,33 @@ +import type { FC } from "react"; + +import { Badge, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +import { formatSnapshotTime } from "@/shared/lib"; + +type InstrumentQuoteStatusProps = { + quote?: { priceAt: null | string; tradingStatus: null | string }; +}; + +const InstrumentQuoteStatus: FC = (props) => { + const { quote } = props; + const { t } = useTranslation(); + + return ( + <> + + {t(`stock.trading.${quote?.tradingStatus}`, { + defaultValue: t("stock.trading.unknown"), + })} + + + {!!quote?.priceAt && ( + + {t("stock.priceAt", { date: formatSnapshotTime(quote.priceAt) })} + + )} + + ); +}; + +export { InstrumentQuoteStatus }; diff --git a/src/features/instrument-details/ui/InstrumentSection.view.tsx b/src/features/instrument-details/ui/InstrumentSection.view.tsx index 9119dd3..64694b9 100644 --- a/src/features/instrument-details/ui/InstrumentSection.view.tsx +++ b/src/features/instrument-details/ui/InstrumentSection.view.tsx @@ -13,7 +13,7 @@ import { import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; -import { formatSnapshotTime } from "@/shared/lib"; +import { SourceSyncStatus } from "@/shared/ui"; import type { SectionState } from "../model/section"; @@ -80,18 +80,13 @@ const InstrumentSection: FC = (props) => { {props.controls} - {!!props.state.stale && {t("investment.stale")}} + {!!props.state.partial && {t("investment.partial")}} - {!!props.state.lastSuccessfulAt && ( - - {t("investment.sourceTime", { - date: formatSnapshotTime(props.state.lastSuccessfulAt), - })} - - )} - {!!props.state.refreshing && ( {t("accounts.refreshing")} diff --git a/src/features/instrument-search/index.ts b/src/features/instrument-search/index.ts new file mode 100644 index 0000000..62c661e --- /dev/null +++ b/src/features/instrument-search/index.ts @@ -0,0 +1,3 @@ +export { useInstrumentSearch } from "./model/useInstrumentSearch"; +export { InstrumentSearch } from "./ui/InstrumentSearch/InstrumentSearch.view"; +export { InstrumentSearchCard } from "./ui/InstrumentSearchCard/InstrumentSearchCard.view"; diff --git a/src/features/instrument-search/model/useInstrumentSearch.ts b/src/features/instrument-search/model/useInstrumentSearch.ts new file mode 100644 index 0000000..19f94af --- /dev/null +++ b/src/features/instrument-search/model/useInstrumentSearch.ts @@ -0,0 +1,25 @@ +import type { ChangeEvent } from "react"; + +import { useDebouncedValue } from "@mantine/hooks"; +import { useSearchParams } from "react-router"; + +const SEARCH_DEBOUNCE_MS = 300; + +function useInstrumentSearch(minLength: number) { + const [parameters, setParameters] = useSearchParams(); + const query = parameters.get("q") ?? ""; + const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS); + + return { + canSearch: query.trim().length >= minLength, + debounced, + isWaiting: query.trim() !== debounced, + onQueryChange: (event: ChangeEvent) => { + const q = event.currentTarget.value; + setParameters(q ? { q } : {}, { replace: true }); + }, + query, + }; +} + +export { useInstrumentSearch }; diff --git a/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.test.tsx b/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.test.tsx new file mode 100644 index 0000000..4f16310 --- /dev/null +++ b/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.test.tsx @@ -0,0 +1,61 @@ +import type { ComponentProps } from "react"; + +import { MantineProvider } from "@mantine/core"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { expect, it, vi } from "vitest"; + +import { InstrumentSearch } from "./InstrumentSearch.view"; + +it("keeps cached results on failure, hides them during a new search and distinguishes partial from empty", () => { + const vm = { + canSearch: true, + error: false, + loading: false, + onQueryChange: vi.fn(), + partial: false, + query: "SBER", + ready: true, + refreshing: false, + retry: vi.fn(), + }; + + const cached = [Сбербанк]; + + const view = ( + state: Partial["vm"]>, + children = cached, + ) => ( + + {children} + + ); + + const { rerender } = render(view({}), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + rerender(view({ code: "INVALID_TBANK_TOKEN", error: true })); + expect(screen.getByRole("link", { name: "Сбербанк" })).toBeVisible(); + expect( + screen.getByRole("link", { name: "Открыть настройки T-Bank" }), + ).toHaveAttribute("href", "/settings"); + fireEvent.click(screen.getByRole("button", { name: "Повторить" })); + expect(vm.retry).toHaveBeenCalledOnce(); + + rerender(view({ loading: true, ready: false })); + expect(screen.queryByRole("link", { name: "Сбербанк" })).not.toBeInTheDocument(); + expect(screen.getByLabelText("Ищем акции")).toBeVisible(); + + rerender(view({ partial: true }, [])); + expect(screen.queryByText(/Акции не найдены/)).not.toBeInTheDocument(); + expect(screen.getByRole("status")).toBeVisible(); + + rerender(view({}, [])); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.getByText(/Акции не найдены/)).toBeVisible(); +}); diff --git a/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.view.tsx b/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.view.tsx new file mode 100644 index 0000000..e56ff5f --- /dev/null +++ b/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.view.tsx @@ -0,0 +1,129 @@ +import type { ChangeEvent, FC, ReactNode } from "react"; + +import { + Alert, + Anchor, + Button, + Skeleton, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; + +import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; + +type InstrumentSearchProps = { + children: ReactNode[]; + kind: "bond" | "stock"; + vm: { + canSearch: boolean; + code?: string; + error: boolean; + loading: boolean; + onQueryChange: (event: ChangeEvent) => void; + partial: boolean; + query: string; + ready: boolean; + refreshing: boolean; + retry: () => void; + }; +}; + +const InstrumentSearchResults: FC = (props) => { + const { children, kind, vm } = props; + const { t } = useTranslation(); + if (vm.loading) return ( + + + + + + + + + + ); + if (!vm.ready) return; + if (children.length > 0) return ( + + {t(`${kind}.searchVariants`)} + + {children} + + ); + + return ( + + {t(vm.partial ? "investment.partial" : `${kind}.noSearchResults`)} + + ); +}; + +const InstrumentSearch: FC = (props) => { + const { kind, vm } = props; + const { t } = useTranslation(); + + return ( + +
+ {t(`${kind}.searchTitle`)} + + + {t(`${kind}.searchSubtitle`)} + +
+ + + + {!!vm.partial && {t("investment.partial")}} + + + + {!vm.canSearch && {t("stock.searchHint")}} + + {!!vm.error && ( + + + + {t( + isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) + || isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken) + ? `${kind}.connect` + : "stock.sectionError", + )} + + + + {t("accounts.openSettings")} + + + + + + )} + + +
+ ); +}; + +export { InstrumentSearch }; diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.module.css b/src/features/instrument-search/ui/InstrumentSearchCard/InstrumentSearchCard.module.css similarity index 100% rename from src/pages/bonds/ui/BondsPage/BondsPage.module.css rename to src/features/instrument-search/ui/InstrumentSearchCard/InstrumentSearchCard.module.css diff --git a/src/features/instrument-search/ui/InstrumentSearchCard/InstrumentSearchCard.view.tsx b/src/features/instrument-search/ui/InstrumentSearchCard/InstrumentSearchCard.view.tsx new file mode 100644 index 0000000..70623f4 --- /dev/null +++ b/src/features/instrument-search/ui/InstrumentSearchCard/InstrumentSearchCard.view.tsx @@ -0,0 +1,38 @@ +import type { FC, PropsWithChildren, ReactNode } from "react"; + +import { Avatar, Paper, Text } from "@mantine/core"; +import { Link } from "react-router"; + +import classes from "./InstrumentSearchCard.module.css"; + +type InstrumentSearchCardProps = PropsWithChildren<{ + instrument: { logoSrc: null | string; name: string; to: string }; + quote: ReactNode; +}>; + +const InstrumentSearchCard: FC = (props) => { + const { children, instrument, quote } = props; + + return ( + + + {instrument.name[0]} + + +
+ {instrument.name} + + {children} +
+ +
{quote}
+
+ ); +}; + +export { InstrumentSearchCard }; diff --git a/src/features/tbank-connection/index.ts b/src/features/tbank-connection/index.ts index 850e209..9b07524 100644 --- a/src/features/tbank-connection/index.ts +++ b/src/features/tbank-connection/index.ts @@ -1,2 +1,6 @@ export { useScreenSync } from "./model/screen-sync"; +export { + InvestmentRefreshState, + type InvestmentRefreshStateProps, +} from "./ui/InvestmentRefreshState/InvestmentRefreshState.view"; export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection"; diff --git a/src/features/tbank-connection/model/connection-boundary.ts b/src/features/tbank-connection/model/connection-boundary.ts index a25a7fe..445e65c 100644 --- a/src/features/tbank-connection/model/connection-boundary.ts +++ b/src/features/tbank-connection/model/connection-boundary.ts @@ -9,7 +9,14 @@ import { type TbankConnectionStatus = paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"]; -const brokerageAccountsPath = "/api/v1/brokerage-accounts"; +function isInvestmentPath(path: string) { + return [ + "/api/v1/brokerage-accounts", + "/api/v1/stocks", + "/api/v1/bonds", + "/api/v1/tbank-sync", + ].some(root => path === root || path.startsWith(`${root}/`)); +} const tbankConnectionQueryOptions = () => apiQueryClient.queryOptions("get", "/api/v1/tbank-connection"); @@ -20,13 +27,7 @@ function commitTbankConnectionBoundary( queryClient: QueryClient, status: TbankConnectionStatus, ) { - invalidateApiRequests( - path => - path.startsWith("/api/v1/brokerage-accounts") - || path.startsWith("/api/v1/stocks") - || path.startsWith("/api/v1/bonds") - || path.startsWith("/api/v1/tbank-sync"), - ); + invalidateApiRequests(isInvestmentPath); queryClient.setQueryData(tbankConnectionQueryKey, status); void queryClient.resetQueries({ predicate: ({ queryKey }) => { @@ -35,13 +36,7 @@ function commitTbankConnectionBoundary( return ( method === "get" && typeof path === "string" - && (path.startsWith("/api/v1/tbank-sync") - || path === "/api/v1/bonds" - || path.startsWith("/api/v1/bonds/") - || path === "/api/v1/stocks" - || path.startsWith("/api/v1/stocks/") - || path === brokerageAccountsPath - || path.startsWith(`${brokerageAccountsPath}/`)) + && isInvestmentPath(path) ); }, }); diff --git a/src/features/tbank-connection/model/screen-sync.ts b/src/features/tbank-connection/model/screen-sync.ts index 325ce28..5bfd3a7 100644 --- a/src/features/tbank-connection/model/screen-sync.ts +++ b/src/features/tbank-connection/model/screen-sync.ts @@ -6,18 +6,15 @@ import { apiErrorCode, apiQueryClient, invalidateResourceAccess, + type InvestmentResourceMeta, isApiErrorCode, - type paths, useAuthenticatedQueries, } from "@/shared/api"; import { type InvestmentScreen, useTbankSync } from "./sync"; -type ResourceMeta - = paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number]; - type ScreenResource = { - meta?: ResourceMeta; + meta?: InvestmentResourceMeta; observe: (version?: string) => Promise; queryKey: QueryKey; transport: { @@ -34,7 +31,7 @@ const STATUS_INTERVAL_MS = 1000; function observeResource( resource: ScreenResource, - latest: Map, + latest: Map, completedId: string | undefined, observed: Map, ) { @@ -106,7 +103,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { meta ? [meta, ...(meta.dependencies ?? [])] : [], ) .flatMap(meta => - meta?.sync.id + meta.sync.id && (meta.sync.state === "running" || meta.sync.state === "queued") ? [meta.sync.id] : [], @@ -143,7 +140,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { invalidateResourceAccess(client, "brokerage", status.error); } - const latest = new Map(); + const latest = new Map(); const orderedStatuses = statuses.toSorted( (a, b) => a.dataUpdatedAt - b.dataUpdatedAt, diff --git a/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.stories.tsx b/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.stories.tsx new file mode 100644 index 0000000..651044d --- /dev/null +++ b/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Stack, Text } from "@mantine/core"; +import { fn } from "storybook/test"; + +import { InvestmentRefreshState } from "./InvestmentRefreshState.view"; + +const meta = { + args: { labelScope: "portfolio", refreshError: false, refreshing: false, retry: fn() }, + argTypes: { + refreshError: { control: "boolean" }, + refreshing: { control: "boolean" }, + retry: { control: false }, + }, + component: InvestmentRefreshState, + parameters: { + docs: { + description: { + component: + "Обновление уже показанных данных портфеля. Синтетические суммы остаются видимыми вместе с InvestmentRefreshState при refetch и его ошибке. Суммы синтетические; это presentation Client Cached Snapshot, без QueryClient и запроса API. Retry регистрирует локальное действие в Actions и не доказывает восстановление API.", + }, + }, + }, + render: arguments_ => ( + + 125 000,50 ₽ · 320 USD + + + + ), + tags: ["autodocs"], + title: "Product/InvestmentRefreshState", +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Idle: Story = { name: "Сохранённые данные" }; +export const Refreshing: Story = { + args: { refreshing: true }, + name: "Фоновое обновление", +}; +export const RefreshError: Story = { + args: { labelScope: "portfolio", refreshError: true }, + name: "Ошибка обновления с retry", +}; + +export const SourceFailure: Story = { + args: { labelScope: "portfolio", refreshError: true, sourceError: true }, + name: "Ошибка синхронизации сохраняет суммы", +}; diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsRefreshState.view.tsx b/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.view.tsx similarity index 59% rename from src/pages/accounts/ui/AccountsPage/components/AccountsRefreshState.view.tsx rename to src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.view.tsx index 089a4ef..72b1f17 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsRefreshState.view.tsx +++ b/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.view.tsx @@ -3,9 +3,10 @@ import type { FC } from "react"; import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { formatSnapshotTime } from "@/shared/lib"; +import { SourceSyncStatus } from "@/shared/ui"; -type AccountsRefreshStateProps = { +type InvestmentRefreshStateProps = { + labelScope?: "accounts" | "portfolio"; lastSuccessfulAt?: null | string; refreshError?: boolean; refreshing?: boolean; @@ -13,19 +14,13 @@ type AccountsRefreshStateProps = { sourceError?: boolean; }; -const AccountsRefreshState: FC = (props) => { - const { refreshError = false, refreshing = false, retry } = props; +const InvestmentRefreshState: FC = (props) => { + const { labelScope = "accounts", refreshError = false, refreshing = false, retry } = props; const { t } = useTranslation(); return ( <> - {!!props.lastSuccessfulAt && ( - - {t("investment.sourceTime", { - date: formatSnapshotTime(props.lastSuccessfulAt), - })} - - )} + {!!refreshing && ( - + - {t("accounts.refreshing")} + {t(`${labelScope}.refreshing`)} )} @@ -53,7 +48,7 @@ const AccountsRefreshState: FC = (props) => { {t( props.sourceError ? "investment.syncFailed" - : "accounts.refreshError", + : `${labelScope}.refreshError`, )}
@@ -63,4 +58,4 @@ const AccountsRefreshState: FC = (props) => { ); }; -export { AccountsRefreshState, type AccountsRefreshStateProps }; +export { InvestmentRefreshState, type InvestmentRefreshStateProps }; diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx index 32492fd..6d7d1b6 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx @@ -292,31 +292,47 @@ describe("TbankConnectionSection", () => { expect(client.getQueryData(operationsQueryKey)).toBeUndefined(); }); - it("preserves status and financial cache when a connection update fails", async () => { - server.use( - http.put("*/api/v1/tbank-connection", () => - HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), - ), - ); + it.each([ + ["INVALID_TBANK_TOKEN", "Проверьте token и его права."], + ["TBANK_UNAVAILABLE", "T-Bank временно недоступен. Повторите попытку позже."], + [undefined, "Не удалось выполнить запрос. Попробуйте ещё раз."], + ])("preserves the draft and cache after %s, then retries successfully", async (code, message) => { + const { promise: response, resolve: finishUpdate } + = Promise.withResolvers(); + + const update = vi.fn() + .mockImplementationOnce(() => + HttpResponse.json({ code }, { status: 503 }), + ) + .mockImplementationOnce(() => response); + + server.use(http.put("*/api/v1/tbank-connection", update)); const client = seededClient(); renderSection(client); await screen.findByText("Не подключено"); - fireEvent.change(screen.getByLabelText("Token T-Bank"), { - target: { value: "replacement-token" }, - }); + const token = screen.getByLabelText("Token T-Bank"); + fireEvent.change(token, { target: { value: "replacement-token" } }); fireEvent.click(screen.getByRole("button", { name: "Подключить" })); - await screen.findByText( - "T-Bank временно недоступен. Повторите попытку позже.", - ); - expect( - screen.getByRole("button", { name: "Подключить" }), - ).toBeInTheDocument(); + await screen.findByText(message); + expect(token).toHaveValue("replacement-token"); + expect(screen.getByText("Не подключено")).toBeVisible(); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeDefined(); expect(client.getQueryData(portfolioQueryKey)).toEqual({ source: "previous-connection", }); + fireEvent.click(screen.getByRole("button", { name: "Подключить" })); + await waitFor(() => expect(update).toHaveBeenCalledTimes(2)); + expect(screen.queryByText(message)).not.toBeInTheDocument(); + expect(token).toBeDisabled(); + expect(token).toHaveValue("replacement-token"); + finishUpdate(HttpResponse.json({ + connected: true, connectedAt: "2026-08-14T00:00:00Z", + })); + expect(await screen.findByText("Подключено с 14.08.2026")).toBeVisible(); + expect(token).toHaveValue(""); + expect(screen.queryByText(message)).not.toBeInTheDocument(); }); it("keeps DELETE recovery inside the dialog and preserves cache until successful retry", async () => { diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.test.tsx deleted file mode 100644 index ec68a16..0000000 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { MantineProvider } from "@mantine/core"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import { - TbankConnectionError, - TbankConnectionLoading, -} from "./TbankConnectionSection.view"; - -describe("TbankConnectionSection state views", () => { - it("renders loading and retries a status error", () => { - const retry = vi.fn(); - - const { rerender } = render( - - - , - ); - - expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute( - "aria-busy", - "true", - ); - rerender( - - - , - ); - fireEvent.click(screen.getByRole("button", { name: "Повторить" })); - expect(retry).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx index 9749021..186882f 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx @@ -41,7 +41,7 @@ const TbankConnectionSectionLoaded: FC = ( t, unavailableMessage, update, - } = useTbankConnectionSectionLoadedVm(props); + } = useTbankConnectionSectionLoadedVm(props.status); return ( @@ -92,7 +92,7 @@ const TbankConnectionSectionLoaded: FC = ( error={errors.token?.message} /> - {requestError && ( + {update.isError && ( {t( isApiErrorCode(requestError, ApiErrorCode.InvalidTbankToken) diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.vm.ts b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.vm.ts index 89f3973..5ec16f6 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.vm.ts +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.vm.ts @@ -14,26 +14,12 @@ import { commitTbankConnectionBoundary, type TbankConnectionStatus, } from "../../model/connection-boundary"; -enum ConnectionErrorCode { - RequestFailed = "REQUEST_ERROR", -} -type TbankConnectionSectionLoadedProps = { - refreshError: boolean; - refreshing: boolean; - retry: () => void; - status: TbankConnectionStatus; -}; - -function useTbankConnectionSectionLoadedVm( - props: TbankConnectionSectionLoadedProps, -) { - const { status } = props; +function useTbankConnectionSectionLoadedVm(status: TbankConnectionStatus) { const { t } = useTranslation(); const queryClient = useQueryClient(); const [isDeleteOpened, setIsDeleteOpened] = useState(false); const removalButtonRef = useRef(null); - const [requestError, setRequestError] = useState(null); const { formState: { errors }, @@ -43,8 +29,6 @@ function useTbankConnectionSectionLoadedVm( } = useForm<{ token: string }>(); const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", { - onError: error => - setRequestError(apiErrorCode(error) ?? ConnectionErrorCode.RequestFailed), onSuccess: (data) => { commitTbankConnectionBoundary(queryClient, data); reset(); @@ -66,6 +50,8 @@ function useTbankConnectionSectionLoadedVm( }, ); + const requestError = apiErrorCode(update.error); + const unavailableMessage = isApiErrorCode( requestError, ApiErrorCode.TbankUnavailable, @@ -80,7 +66,6 @@ function useTbankConnectionSectionLoadedVm( }, [isDeleteOpened, remove.isError]); const onSubmit = handleSubmit(({ token }) => { - setRequestError(null); update.mutate({ body: { token } }); }); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx index 4968f31..4a75e0c 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx @@ -18,7 +18,7 @@ import { useLocation, useNavigate, } from "react-router"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; @@ -47,13 +47,40 @@ const Navigation: FC = () => { ); }; +function renderEvents(accountId: string, searches: string[]) { + return render( + + + + `/accounts/${accountId}/events${search}`, + )} + initialIndex={0} + > + + + + } + path="/accounts/:accountId/events" + /> + + + + , + ); +} + describe("Upcoming event date filters", () => { - it("keeps reads and sync disabled without an account scope", async () => { + beforeEach(() => { configureAuthClient({ accessToken: () => "token", clear: vi.fn(), refresh: () => Promise.resolve(true), }); + }); + + it("keeps reads and sync disabled without an account scope", async () => { const requests: string[] = []; server.use( http.all("*/api/v1/*", ({ request }) => { @@ -87,11 +114,6 @@ describe("Upcoming event date filters", () => { ])( "keeps event calendar dates in GET and source sync requests: %s", async (search) => { - configureAuthClient({ - accessToken: () => "token", - clear: vi.fn(), - refresh: () => Promise.resolve(true), - }); const requested: string[] = []; const syncRequests: unknown[] = []; server.use( @@ -124,12 +146,14 @@ describe("Upcoming event date filters", () => { const queryClient = createTestQueryClient(); const { result } = renderHook( - () => useAccountEventsPageViewModel("all"), + () => useAccountEventsPageViewModel(), { wrapper: ({ children }) => ( - - {children} + + + + ), @@ -153,11 +177,6 @@ describe("Upcoming event date filters", () => { it.each(["account-1", "all"])( "normalizes invalid links and history while preserving one-sided future dates for %s", async (accountId) => { - configureAuthClient({ - accessToken: () => "token", - clear: vi.fn(), - refresh: () => Promise.resolve(true), - }); const searches: string[] = []; server.use( http.get( @@ -169,31 +188,13 @@ describe("Upcoming event date filters", () => { }, ), ); - render( - - - `/accounts/${accountId}/events${search}`)} - initialIndex={0} - > - - - - } - path="/accounts/:accountId/events" - /> - - - - , - ); + renderEvents(accountId, [ + "?from=bad&to=2099-02-28", + "?from=2099-02-30", + "?from=2099-03-01&to=2099-02-28", + "?from=2099-02-01", + "?to=2099-02-28", + ]); await screen.findByLabelText("Период"); for (const expected of ["", "", "?from=2099-02-01", "?to=2099-02-28"]) { @@ -221,11 +222,6 @@ describe("Upcoming event date filters", () => { it.each(["account-1", "all"])( "lets users reset filters after a request failure for %s", async (accountId) => { - configureAuthClient({ - accessToken: () => "token", - clear: vi.fn(), - refresh: () => Promise.resolve(true), - }); server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/events", @@ -235,24 +231,7 @@ describe("Upcoming event date filters", () => { : HttpResponse.json(resource({ events: [] })), ), ); - render( - - - - - - - } - path="/accounts/:accountId/events" - /> - - - - , - ); + renderEvents(accountId, ["?from=2099-02-01"]); await screen.findByRole("button", { name: "Повторить" }); fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); await screen.findByLabelText("Период"); @@ -263,11 +242,6 @@ describe("Upcoming event date filters", () => { it.each(["account-1", "all"])( "applies dates only on submit, restores URL dates, and resets from empty results for %s", async (accountId) => { - configureAuthClient({ - accessToken: () => "token", - clear: vi.fn(), - refresh: () => Promise.resolve(true), - }); const searches: string[] = []; server.use( http.get( @@ -286,26 +260,7 @@ describe("Upcoming event date filters", () => { }, ), ); - render( - - - - - - - } - path="/accounts/:accountId/events" - /> - - - - , - ); + renderEvents(accountId, ["?from=2099-02-01&to=2099-02-28"]); const from = await screen.findByLabelText("Период"); expect(from).toHaveTextContent("01.02.2099 – 28.02.2099"); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); @@ -373,10 +328,14 @@ it("retains the previous complete forecast and timestamp when its source refresh ); const queryClient = createTestQueryClient(); - const { result } = renderHook(() => useAccountEventsPageViewModel("all"), { + const { result } = renderHook(() => useAccountEventsPageViewModel(), { wrapper: ({ children }) => ( - {children} + + + + + ), }); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx index 3d1025c..9308ae7 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx @@ -3,6 +3,8 @@ import { fireEvent, render, screen, within } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type * as BrokerageEvents from "@/entities/brokerage-event"; + import { useBrokerageEvents } from "@/entities/brokerage-event"; import { AccountEventsPage } from "./AccountEventsPage"; @@ -18,7 +20,7 @@ vi.mock("@/features/tbank-connection", () => ({ }), })); vi.mock("@/entities/brokerage-event", async importOriginal => ({ - ...(await importOriginal()), + ...(await importOriginal()), useBrokerageEvents: vi.fn(), })); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx index 33f0160..682dd4a 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx @@ -5,10 +5,8 @@ import { AccountEventsPageError } from "./components/AccountEventsPageError.view import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view"; import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view"; -type AccountEventsPageProps = { accountId?: string }; - -const AccountEventsPage: FC = (props) => { - const viewModel = useAccountEventsPageViewModel(props.accountId); +const AccountEventsPage: FC = () => { + const viewModel = useAccountEventsPageViewModel(); if (viewModel.state === "loading") return ; return viewModel.state === "error" diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index 20d0a67..f9707e0 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -1,23 +1,21 @@ -import { useEffect, useState } from "react"; -import { useParams, useSearchParams } from "react-router"; +import { useState } from "react"; +import { useParams } from "react-router"; import { type BrokerageEvent, useBrokerageEvents, } from "@/entities/brokerage-event"; import { useScreenSync } from "@/features/tbank-connection"; -import { isValidPeriod } from "@/shared/lib"; +import { isValidPeriod, useUrlPeriod } from "@/shared/lib"; type AccountEventsPageViewModel = | { canApply: boolean; - draftFrom: null | string; - draftTo: null | string; + draftPeriod: [null | string, null | string]; events: BrokerageEvent[]; lastSuccessfulAt?: null | string; onApply: () => void; - onDraftFromChange: (value: null | string) => void; - onDraftToChange: (value: null | string) => void; + onPeriodChange: (value: [null | string, null | string]) => void; onReset: () => void; partial?: boolean; refreshError: boolean; @@ -29,43 +27,28 @@ type AccountEventsPageViewModel | { onReset: () => void; retry: () => void; state: "error" } | { state: "loading" }; -function useAccountEventsPageViewModel( - scopeId?: string, -): AccountEventsPageViewModel { - const { accountId: routeAccountId } = useParams(); - const accountId = scopeId ?? routeAccountId ?? ""; - const [searchParameters, setSearchParameters] = useSearchParams(); - const urlFrom = searchParameters.get("from"); - const urlTo = searchParameters.get("to"); - const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo); +function useAccountEventsPageViewModel(): AccountEventsPageViewModel { + const { accountId = "" } = useParams(); - const appliedFrom = isValidUrlPeriod - ? urlFrom - : null; + const { + from: appliedFrom, + setParameters: setSearchParameters, + to: appliedTo, + } = useUrlPeriod(); - const appliedTo = isValidUrlPeriod - ? urlTo - : null; + const [draftPeriod, setDraftPeriod] = useState<[ + null | string, + null | string, + ]>([appliedFrom, appliedTo]); - const [draftFrom, setDraftFrom] = useState(appliedFrom); - const [draftTo, setDraftTo] = useState(appliedTo); - useEffect(() => { - if (isValidUrlPeriod) return; - const normalized = new URLSearchParams(searchParameters); - normalized.delete("from"); - normalized.delete("to"); - setSearchParameters(normalized, { replace: true }); - }, [isValidUrlPeriod, searchParameters, setSearchParameters]); + const [draftFrom, draftTo] = draftPeriod; - const [previousPeriod, setPreviousPeriod] = useState([ - appliedFrom, - appliedTo, - ]); + const scope = JSON.stringify([accountId, appliedFrom, appliedTo]); + const [previousScope, setPreviousScope] = useState(scope); - if (previousPeriod[0] !== appliedFrom || previousPeriod[1] !== appliedTo) { - setPreviousPeriod([appliedFrom, appliedTo]); - setDraftFrom(appliedFrom); - setDraftTo(appliedTo); + if (previousScope !== scope) { + setPreviousScope(scope); + setDraftPeriod([appliedFrom, appliedTo]); } const events = useBrokerageEvents(accountId, { @@ -86,8 +69,7 @@ function useAccountEventsPageViewModel( if (events.isLoading) return { state: "loading" }; const onReset = () => { - setDraftFrom(null); - setDraftTo(null); + setDraftPeriod([null, null]); setSearchParameters({}); }; @@ -105,8 +87,7 @@ function useAccountEventsPageViewModel( canApply: (draftFrom !== appliedFrom || draftTo !== appliedTo) && isValidPeriod(draftFrom, draftTo), - draftFrom, - draftTo, + draftPeriod, events: events.data.events, lastSuccessfulAt: events.meta?.lastSuccessfulAt, onApply: () => { @@ -116,8 +97,7 @@ function useAccountEventsPageViewModel( ...(draftTo && { to: draftTo }), }); }, - onDraftFromChange: setDraftFrom, - onDraftToChange: setDraftTo, + onPeriodChange: setDraftPeriod, onReset, partial: events.meta?.availability !== "complete", refreshError: events.isError || sync.sourceError || sync.transportError, diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx index 8956252..624ab0c 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -10,14 +10,17 @@ import { Title, } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; +import { useTranslation } from "react-i18next"; -import { BrokerageEventCard } from "@/entities/brokerage-event"; -import { formatSnapshotTime } from "@/shared/lib"; +import { + BrokerageEventCard, + brokerageEventKey, +} from "@/entities/brokerage-event"; +import { SourceSyncStatus } from "@/shared/ui"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view"; -import { useAccountEventsPageLoadedVm } from "./AccountEventsPageLoaded.vm"; type AccountEventsPageLoadedProps = Extract< AccountEventsPageViewModel, @@ -33,15 +36,7 @@ const AccountEventsPageLoadedEvents: FC = ( {props.events.map(event => ( ))} @@ -56,17 +51,11 @@ const AccountEventsPageLoadedEvents: FC = ( }; const AccountEventsPageLoaded: FC = (props) => { - const { onPeriodChange, t } = useAccountEventsPageLoadedVm(props); + const { t } = useTranslation(); return ( - {!!props.lastSuccessfulAt && ( - - {t("investment.sourceTime", { - date: formatSnapshotTime(props.lastSuccessfulAt), - })} - - )} + {t("events.title")} @@ -100,13 +89,13 @@ const AccountEventsPageLoaded: FC = (props) => { clearable clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }} label={t("ledger.period")} - onChange={onPeriodChange} + onChange={props.onPeriodChange} popoverProps={{ middlewares: { shift: { crossAxis: true } }, returnFocus: true, }} type="range" - value={[props.draftFrom, props.draftTo]} + value={props.draftPeriod} valueFormat="DD.MM.YYYY" /> diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts deleted file mode 100644 index af03a32..0000000 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useTranslation } from "react-i18next"; - -import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; - -type AccountEventsPageLoadedProps = Extract< - AccountEventsPageViewModel, - { state: "loaded" } ->; - -function useAccountEventsPageLoadedVm(props: AccountEventsPageLoadedProps) { - const { t } = useTranslation(); - - const onPeriodChange: (value: [null | string, null | string]) => void = ([ - from, - to, - ]) => { - props.onDraftFromChange(from); - props.onDraftToChange(to); - }; - - return { onPeriodChange, t }; -} - -export { useAccountEventsPageLoadedVm }; diff --git a/src/pages/account-income/model/display.test.ts b/src/pages/account-income/model/display.test.ts new file mode 100644 index 0000000..60b7248 --- /dev/null +++ b/src/pages/account-income/model/display.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "vitest"; + +import type { IncomeAnalytics } from "@/entities/income"; + +import { resource } from "@/test/resource"; + +import { toIncomeDisplayModel } from "./display"; + +const analytics: IncomeAnalytics = { + accountId: "account-1", + bucketGranularity: "day", + buckets: [], + calculatedAt: "2026-08-27T00:00:00Z", + entries: [], + instruments: [], + period: { from: "2026-08-01", to: "2026-08-27" }, + previous: { + period: { from: "2026-07-01", to: "2026-07-31" }, + totalsByCurrency: [comparison("RUB")], + }, + totalsByCurrency: [], + yearComparison: null, +}; + +function comparison(currency: string) { + return { + compared: summary(currency, "1", "0"), + currency, + difference: summary(currency, "9", "2"), + selected: summary(currency, "10", "2"), + }; +} + +function money(currency: string, value: string) { + return { currency, value }; +} + +function summary(currency: string, coupon: string, dividend: string) { + return { + coupon: money(currency, coupon), + currency, + dividend: money(currency, dividend), + total: money(currency, String(Number(coupon) + Number(dividend))), + }; +} + +describe("income membership validation", () => { + const entry: IncomeAnalytics["entries"][number] = { + accruedInterest: null, + category: "income", + commission: null, + description: "Купон", + figi: "FIGI", + id: "coupon-1", + instrumentKey: "income-group", + instrumentType: "bond", + instrumentUid: null, + logoName: null, + occurredAt: "2026-08-01T12:00:00Z", + payment: money("RUB", "10"), + price: null, + quantity: null, + ticker: "BOND", + type: "OPERATION_TYPE_COUPON", + }; + + const instrument: IncomeAnalytics["instruments"][number] = { + ...summary("RUB", "10", "0"), + displayName: "BOND", + figi: "FIGI", + instrumentType: "bond", + instrumentUid: null, + key: entry.instrumentKey, + receiptCount: 1, + ticker: "BOND", + }; + + const valid: IncomeAnalytics = { + ...analytics, + entries: [entry], + instruments: [instrument], + totalsByCurrency: [summary("RUB", "10", "0")], + }; + + it.each([ + ["missing key", undefined], + ["empty key", ""], + ["whitespace key", " "], + ["non-string key", 12], + ["unknown key", "not-in-breakdown"], + ])("rejects an entry with %s", (_name, instrumentKey) => { + // Deliberately malformed API data exercises the runtime validation boundary. + const response = { + ...valid, + entries: [{ ...entry, instrumentKey }], + } as unknown as IncomeAnalytics; + + expect(toIncomeDisplayModel(response)).toBeNull(); + }); + + it.each([ + ["duplicate instrument keys", { instruments: [instrument, instrument] }], + ["currency mismatch", { entries: [{ ...entry, payment: money("USD", "10") }] }], + ["receipt count mismatch", { instruments: [{ ...instrument, receiptCount: 2 }] }], + ["missing entries", { entries: [] }], + ["empty instrument key", { instruments: [{ ...instrument, key: "" }] }], + ])("rejects %s", (_name, invalid) => { + expect(toIncomeDisplayModel({ ...valid, ...invalid })).toBeNull(); + }); +}); + +describe("income comparison display", () => { + it.each([ + ["25", "100", "+25 %"], + ["-25", "100", "-25 %"], + ["0", "100", "0 %"], + ["-0.000", "100", "0 %"], + ["0.0001", "100", "0 %"], + ["1", "3", "+33,3 %"], + ["0.01", "0.04", "+25 %"], + ["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"], + [ + "10", + "0", + null, + ], + [ + "0", + "0", + null, + ], + [ + "10", + "-0.00", + null, + ], + [ + "10", + "-100", + null, + ], + [ + "9".repeat(400), + "1", + null, + ], + [ + "1", + "9".repeat(400), + null, + ], + ])("formats change %s against base %s", (difference, base, expected) => { + const value = comparison("RUB"); + value.difference.coupon = money("RUB", difference); + value.compared.coupon = money("RUB", base); + + const display = toIncomeDisplayModel({ + ...analytics, + yearComparison: { + period: { from: "2025-08-01", to: "2025-08-27" }, + totalsByCurrency: [value], + }, + }); + + const result = display?.comparison?.values[0]; + expect( + result?.percentages.coupon?.replaceAll("\u{A0}", " ") + + ?? null, + ).toBe(expected); + expect(result?.difference.coupon.value).toBe(difference); + expect(result?.compared.coupon.value).toBe(base); + }); + + it("formats calendar ranges across years and preserves separate currencies", () => { + const display = toIncomeDisplayModel({ + ...analytics, + period: { from: "2025-12-01", to: "2026-01-09" }, + yearComparison: { + period: { from: "2024-12-01", to: "2025-01-09" }, + totalsByCurrency: [comparison("RUB"), comparison("USD")], + }, + }); + + expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe( + "01.12.2025 – 09.01.2026", + ); + expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe( + "01.12.2024 – 09.01.2025", + ); + expect(display?.comparison?.values.map(value => value.currency)).toEqual([ + "RUB", + "USD", + ]); + }); +}); + +it("maps comparison notices by role regardless of dependency order or resource key", () => { + const selected = { ...resource(analytics).meta, role: "selected" as const }; + + const previous + = { + ...resource(null).meta, + resourceKey: "opaque/year-looking-key", + role: "previous" as const, + sync: { + errorCode: "TBANK_UNAVAILABLE", + id: null, + state: "failed" as const, + }, + }; + + const year = { + ...resource(null).meta, + resourceKey: "opaque/previous-looking-key", + role: "year" as const, + unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", + }; + + for (const dependencies of [ + [selected, previous, year], + [year, selected, previous], + ]) { + const display = toIncomeDisplayModel(analytics, { + ...resource(analytics).meta, + dependencies, + }); + + expect(display?.previousNotice).toBe("comparisonFailed"); + expect(display?.yearNotice).toBe("yearUnavailable"); + expect(display?.previousComparison).not.toBeNull(); + } +}); diff --git a/src/pages/account-income/model/display.ts b/src/pages/account-income/model/display.ts new file mode 100644 index 0000000..22b8d80 --- /dev/null +++ b/src/pages/account-income/model/display.ts @@ -0,0 +1,172 @@ +import type { IncomeAnalytics, IncomeAnalyticsMeta } from "@/entities/income"; + +import { formatDate } from "@/shared/lib"; + +import type { IncomePeriod } from "./period"; + +type ComparisonNotice + = | "comparisonFailed" + | "comparisonPending" + | "previousUnavailable" + | "yearUnavailable"; + +type IncomeDisplayModel = { + comparison: null + | { + comparedPeriod: string; + selectedPeriod: string; + values: (NonNullable< + IncomeAnalytics["yearComparison"] + >["totalsByCurrency"][number] & { + percentages: Record<"coupon" + | "dividend" + | "total", null | string>; + })[]; + }; + entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; + previousComparison: IncomeDisplayModel["comparison"]; + previousNotice: ComparisonNotice; + yearNotice: ComparisonNotice; +}; + +function comparisonDisplay( + period: IncomePeriod, + comparison: IncomeAnalytics["yearComparison"], +): IncomeDisplayModel["comparison"] { + if (!comparison) + return null; + + return { + comparedPeriod: formatComparisonPeriod(comparison.period), + selectedPeriod: formatComparisonPeriod(period), + values: comparison.totalsByCurrency.map(value => ({ + ...value, + percentages: { + coupon: formatIncomeChange( + value.difference.coupon.value, + value.compared.coupon.value, + ), + dividend: formatIncomeChange( + value.difference.dividend.value, + value.compared.dividend.value, + ), + total: formatIncomeChange( + value.difference.total.value, + value.compared.total.value, + ), + }, + })), + }; +} + +function comparisonNotice( + dependency: + IncomeAnalyticsMeta["dependencies"][number] | undefined, + unavailable: "previousUnavailable" | "yearUnavailable", +): ComparisonNotice { + if (!dependency || dependency.unavailableReason) return unavailable; + + return dependency.sync.state === "failed" + ? "comparisonFailed" + : "comparisonPending"; +} + +function formatComparisonPeriod(period: IncomePeriod): string { + return `${formatDate(period.from)} – ${formatDate(period.to)}`; +} + +function formatIncomeChange( + difference: string, + compared: string, +): null | string { + // Display-only ratio; server-provided Money values remain exact decimal strings. + const base = Number(compared); + if (!Number.isFinite(base) || base <= 0) + return null; + const ratio = Number(difference) / base; + if (!Number.isFinite(ratio)) + return null; + + return new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: 1, + signDisplay: "exceptZero", + style: "percent", + }).format(ratio); +} + +function toIncomeDisplayModel( + analytics: IncomeAnalytics, + meta?: IncomeAnalyticsMeta, +): IncomeDisplayModel | null { + const instruments = new Map< + string, + { + currency: string; + includedCount: number; + receiptCount: number; + } + >(); + + const currencies = new Set( + analytics.totalsByCurrency.map(({ currency }) => currency), + ); + + for (const instrument of analytics.instruments) { + if ( + typeof instrument.key !== "string" + || !instrument.key.trim() + || instruments.has(instrument.key) + || !currencies.has(instrument.total.currency) + ) + + return null; + instruments.set(instrument.key, { + currency: instrument.total.currency, + includedCount: 0, + receiptCount: instrument.receiptCount, + }); + } + + const entries: IncomeDisplayModel["entries"] = []; + const occurrences = new Map(); + + for (const entry of analytics.entries) { + if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim()) + return null; + const instrument = instruments.get(entry.instrumentKey); + if (!instrument || entry.payment?.currency !== instrument.currency) + return null; + const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]); + const occurrence = occurrences.get(occurrenceKey) ?? 0; + occurrences.set(occurrenceKey, occurrence + 1); + instrument.includedCount += 1; + entries.push({ + key: JSON.stringify([ + entry.instrumentKey, + entry.id, + occurrence, + entry.logoName, + ]), + operation: entry, + }); + } + + for (const instrument of instruments.values()) { + if (instrument.includedCount !== instrument.receiptCount) + return null; + } + + const dependencies = meta?.dependencies ?? []; + const previous = dependencies.find(({ role }) => role === "previous"); + const year = dependencies.find(({ role }) => role === "year"); + + return { + comparison: comparisonDisplay(analytics.period, analytics.yearComparison), + entries, + previousComparison: comparisonDisplay(analytics.period, analytics.previous), + previousNotice: comparisonNotice(previous, "previousUnavailable"), + yearNotice: comparisonNotice(year, "yearUnavailable"), + }; +} + +export { type IncomeDisplayModel, toIncomeDisplayModel }; diff --git a/src/pages/account-income/model/income.schema.ts b/src/pages/account-income/model/income.schema.ts index 9d22512..cc2c459 100644 --- a/src/pages/account-income/model/income.schema.ts +++ b/src/pages/account-income/model/income.schema.ts @@ -1,8 +1,9 @@ import { z } from "zod"; import { validationMessage } from "@/shared/i18n"; +import { moscowDate } from "@/shared/lib"; -import { moscowDate, parseDraftDate } from "./period"; +import { parseDraftDate } from "./period"; const draftDate = z .string() diff --git a/src/pages/account-income/model/period.ts b/src/pages/account-income/model/period.ts index 4662a7e..fb3a5c5 100644 --- a/src/pages/account-income/model/period.ts +++ b/src/pages/account-income/model/period.ts @@ -1,47 +1,22 @@ -const ISO_DATE_LENGTH = 10; +import { calendarDate } from "@/shared/lib"; + const DATE_PART_COUNT = 3; type IncomePeriod = { from: string; to: string }; -const datePattern = /^\d{4}-\d{2}-\d{2}$/; - function formatDraftDate(value: string): string { const [year, month, day] = value.split("-", DATE_PART_COUNT); return `${day}.${month}.${year}`; } -function isDate(value: string): boolean { - if (!datePattern.test(value)) return false; - const parsed = new Date(`${value}T00:00:00Z`); - - return ( - Number.isFinite(parsed.valueOf()) - && parsed.toISOString().slice(0, ISO_DATE_LENGTH) === value - ); -} - -function moscowDate(value: Date): string { - const parts = new Intl.DateTimeFormat("en-CA", { - day: "2-digit", - month: "2-digit", - timeZone: "Europe/Moscow", - year: "numeric", - }).formatToParts(value); - - const part = (type: string) => - parts.find(item => item.type === type)?.value; - - return `${part("year")}-${part("month")}-${part("day")}`; -} - function parseDraftDate(value: string): null | string { if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return null; const [day, month, year] = value.split(".", DATE_PART_COUNT); const date = `${year}-${month}-${day}`; - return isDate(date) + return calendarDate.safeParse(date).success ? date : null; } @@ -50,8 +25,5 @@ export { formatDraftDate, type IncomePeriod as IncomeForm, type IncomePeriod, - isDate, - ISO_DATE_LENGTH, - moscowDate, parseDraftDate, }; diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css index cf9807e..2e737bc 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css @@ -22,13 +22,6 @@ min-width: 0; } -/* Keep the reused Ledger row's full text readable inside income cards. */ -.entries :global(.mantine-Text-root) { - -webkit-line-clamp: unset; - white-space: normal; - overflow-wrap: anywhere; -} - .comparison { container-type: inline-size; } diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index 614b29d..b52dbb8 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -30,10 +30,9 @@ import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; -import { isDate } from "../../model/period"; +import { toIncomeDisplayModel } from "../../model/display"; import { AccountIncomePage } from "./AccountIncomePage"; import { - toIncomeDisplayModel, useAccountIncomePageViewModel, } from "./AccountIncomePage.vm"; import { IncomePayments } from "./components/IncomePayments.view"; @@ -79,7 +78,7 @@ const analytics }, totalsByCurrency: [summary("RUB", "10", "2")], yearComparison: null, - } satisfies IncomeAnalytics; + } satisfies IncomeAnalytics & { instruments: [unknown] }; function comparison(currency: string) { return { @@ -325,17 +324,11 @@ describe("AccountIncomePage", () => { expect(within(panel).getAllByRole("listitem")).toHaveLength(2); }); - it.each([ - ["missing key", undefined], - ["empty key", ""], - ["whitespace key", " "], - ["non-string key", 12], - ["unknown key", "not-in-breakdown"], - ])("rejects %s and recovers through retry", async (_name, instrumentKey) => { + it("rejects inconsistent analytics and recovers through retry", async () => { let response = { ...analytics, entries: [ - { ...analytics.entries[0], instrumentKey }, + { ...analytics.entries[0], instrumentKey: "not-in-breakdown" }, analytics.entries[1], ], }; @@ -357,51 +350,6 @@ describe("AccountIncomePage", () => { ).toBeInTheDocument(); }); - it.each([ - [ - "duplicate instrument keys", - { - ...analytics, - instruments: [...analytics.instruments, analytics.instruments[0]], - }, - ], - [ - "currency mismatch", - { - ...analytics, - entries: [ - { ...analytics.entries[0], payment: money("USD", "10") }, - analytics.entries[1], - ], - }, - ], - [ - "receipt count mismatch", - { - ...analytics, - instruments: [{ ...analytics.instruments[0], receiptCount: 3 }], - }, - ], - ["missing entries", { ...analytics, entries: [] }], - [ - "empty instrument key", - { ...analytics, instruments: [{ ...analytics.instruments[0], key: "" }] }, - ], - ])("shows a local error for %s", async (_name, response) => { - server.use( - http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json(resource(response)), - ), - ); - renderPage(); - expect( - await screen.findByRole("button", { name: "Повторить" }), - ).toBeInTheDocument(); - expect( - screen.queryByRole("region", { name: "Выплаты" }), - ).not.toBeInTheDocument(); - }); - it("accepts an empty analytics response", async () => { server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => @@ -469,12 +417,6 @@ describe("AccountIncomePage", () => { expect(within(panel).getByText("−2 ₽")).toBeInTheDocument(); }); - it("rejects malformed calendar dates without throwing", () => { - expect(isDate("2026-99-99")).toBe(false); - expect(isDate("2026-02-29")).toBe(false); - expect(isDate("2024-02-29")).toBe(true); - }); - it("renders realized text and comparisons without subscribing to events", async () => { const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 })); server.use( @@ -584,92 +526,6 @@ describe("AccountIncomePage", () => { }); }); -describe("income comparison display", () => { - it.each([ - ["25", "100", "+25 %"], - ["-25", "100", "-25 %"], - ["0", "100", "0 %"], - ["-0.000", "100", "0 %"], - ["0.0001", "100", "0 %"], - ["1", "3", "+33,3 %"], - ["0.01", "0.04", "+25 %"], - ["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"], - [ - "10", - "0", - null, - ], - [ - "0", - "0", - null, - ], - [ - "10", - "-0.00", - null, - ], - [ - "10", - "-100", - null, - ], - [ - "9".repeat(400), - "1", - null, - ], - [ - "1", - "9".repeat(400), - null, - ], - ])("formats change %s against base %s", (difference, base, expected) => { - const value = comparison("RUB"); - value.difference.coupon = money("RUB", difference); - value.compared.coupon = money("RUB", base); - - const display = toIncomeDisplayModel({ - ...analytics, - yearComparison: { - period: { from: "2025-08-01", to: "2025-08-27" }, - totalsByCurrency: [value], - }, - }); - - const result = display?.comparison?.values[0]; - expect( - result?.percentages.coupon?.replaceAll("\u{A0}", " ") - - ?? null, - ).toBe(expected); - expect(result?.difference.coupon.value).toBe(difference); - expect(result?.compared.coupon.value).toBe(base); - }); - - it("formats calendar ranges across years and preserves separate currencies", () => { - const display = toIncomeDisplayModel({ - ...analytics, - period: { from: "2025-12-01", to: "2026-01-09" }, - yearComparison: { - period: { from: "2024-12-01", to: "2025-01-09" }, - totalsByCurrency: [comparison("RUB"), comparison("USD")], - }, - }); - - expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe( - "01.12.2025 – 09.01.2026", - ); - expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe( - "01.12.2024 – 09.01.2025", - ); - expect(display?.comparison?.values.map(value => value.currency)).toEqual([ - "RUB", - "USD", - ]); - }); -}); - const LocationProbe: FC = () => { const location = useLocation(); const navigate = useNavigate(); @@ -963,8 +819,6 @@ describe("income period and snapshot boundary", () => { expect( screen.getByRole("heading", { name: "Выплаты" }), ).toBeInTheDocument(); - expect(screen.queryByRole("heading", { level: 3 })).not.toBeInTheDocument(); - expect(screen.queryByRole("heading", { level: 4 })).not.toBeInTheDocument(); expect(screen.queryByText("RUB")).not.toBeInTheDocument(); expect(screen.queryByText("USD")).not.toBeInTheDocument(); const cards = screen.getAllByRole("listitem"); @@ -974,47 +828,9 @@ describe("income period and snapshot boundary", () => { expect(cards[2]).toHaveTextContent("−3 ₽"); expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument(); expect(screen.queryByRole("button")).not.toBeInTheDocument(); - expect(screen.getAllByText("Купон")).toHaveLength(6); }); }); -it("maps comparison notices by role regardless of dependency order or resource key", () => { - const selected = { ...resource(analytics).meta, role: "selected" as const }; - - const previous - = { - ...resource(null).meta, - resourceKey: "opaque/year-looking-key", - role: "previous" as const, - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: null, - state: "failed" as const, - }, - }; - - const year = { - ...resource(null).meta, - resourceKey: "opaque/previous-looking-key", - role: "year" as const, - unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", - }; - - for (const dependencies of [ - [selected, previous, year], - [year, selected, previous], - ]) { - const display = toIncomeDisplayModel(analytics, { - ...resource(analytics).meta, - dependencies, - }); - - expect(display?.previousNotice).toBe("comparisonFailed"); - expect(display?.yearNotice).toBe("yearUnavailable"); - expect(display?.previousComparison).not.toBeNull(); - } -}); - it.each([false, true])( "shows unavailable comparisons for absent roles without positional fallback (legacy=%s)", async (legacy) => { diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx index d9217bb..f272f09 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx @@ -5,10 +5,8 @@ import { AccountIncomePageError } from "./components/AccountIncomePageError.view import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view"; import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view"; -type AccountIncomePageProps = { accountId?: string }; - -const AccountIncomePage: FC = (props) => { - const viewModel = useAccountIncomePageViewModel(props.accountId); +const AccountIncomePage: FC = () => { + const viewModel = useAccountIncomePageViewModel(); if (viewModel.state === "loading") return ; return viewModel.state === "error" diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index ab419df..3f22620 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -5,16 +5,17 @@ import { useParams, useSearchParams } from "react-router"; import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { useScreenSync } from "@/features/tbank-connection"; -import { formatDate } from "@/shared/lib"; +import { isValidPeriod, moscowDate } from "@/shared/lib"; +import { + type IncomeDisplayModel, + toIncomeDisplayModel, +} from "../../model/display"; import { incomeFormSchema } from "../../model/income.schema"; import { formatDraftDate, type IncomeForm, type IncomePeriod, - isDate, - ISO_DATE_LENGTH, - moscowDate, parseDraftDate, } from "../../model/period"; @@ -43,38 +44,8 @@ type AccountIncomePageViewModel } | { state: "loading" }; -type ComparisonNotice - = | "comparisonFailed" - | "comparisonPending" - | "previousUnavailable" - | "yearUnavailable"; - -type IncomeDisplayModel = { - comparison: null - | { - comparedPeriod: string; - selectedPeriod: string; - values: (NonNullable< - IncomeAnalytics["yearComparison"] - >["totalsByCurrency"][number] & { - percentages: Record<"coupon" - | "dividend" - | "total", null | string>; - })[]; - }; - entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; - previousComparison: IncomeDisplayModel["comparison"]; - previousNotice: ComparisonNotice; - yearNotice: ComparisonNotice; -}; - -type IncomeMeta = ReturnType["meta"]; - -const useAccountIncomePageViewModel = ( - scopeId?: string, -): AccountIncomePageViewModel => { - const { accountId: routeAccountId } = useParams(); - const accountId = scopeId ?? routeAccountId ?? ""; +const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { + const { accountId = "" } = useParams(); const [searchParameters, setSearchParameters] = useSearchParams(); const requested = useMemo( @@ -86,7 +57,7 @@ const useAccountIncomePageViewModel = ( ); const period = useMemo( - () => (isValidPeriod(requested) ? requested : defaultPeriod()), + () => (isValidIncomePeriod(requested) ? requested : defaultPeriod()), [requested], ); @@ -104,12 +75,12 @@ const useAccountIncomePageViewModel = ( ); useEffect(() => { - if (!isValidPeriod(requested)) + if (!isValidIncomePeriod(requested)) setSearchParameters(period, { replace: true }); }, [period, requested, setSearchParameters]); useEffect(() => { form.reset(draftPeriod(period)); - }, [form, period]); + }, [accountId, form, period]); if (analytics.isLoading || !analytics.data) { return analytics.isError || analytics.meta?.sync.state === "failed" @@ -143,7 +114,7 @@ const useAccountIncomePageViewModel = ( analytics: analytics.data, display, form, - formRevision: `${period.from}:${period.to}:${resetRevision}`, + formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`, lastSuccessfulAt: analytics.meta?.lastSuccessfulAt, onReset, onSubmit, @@ -156,48 +127,6 @@ const useAccountIncomePageViewModel = ( }; }; -function comparisonDisplay( - period: IncomePeriod, - comparison: IncomeAnalytics["yearComparison"], -): IncomeDisplayModel["comparison"] { - if (!comparison) - return null; - - return { - comparedPeriod: formatComparisonPeriod(comparison.period), - selectedPeriod: formatComparisonPeriod(period), - values: comparison.totalsByCurrency.map(value => ({ - ...value, - percentages: { - coupon: formatIncomeChange( - value.difference.coupon.value, - value.compared.coupon.value, - ), - dividend: formatIncomeChange( - value.difference.dividend.value, - value.compared.dividend.value, - ), - total: formatIncomeChange( - value.difference.total.value, - value.compared.total.value, - ), - }, - })), - }; -} - -function comparisonNotice( - dependency: - NonNullable["dependencies"]>[number] | undefined, - unavailable: "previousUnavailable" | "yearUnavailable", -): ComparisonNotice { - if (!dependency || dependency.unavailableReason) return unavailable; - - return dependency.sync.state === "failed" - ? "comparisonFailed" - : "comparisonPending"; -} - function defaultPeriod(): IncomePeriod { const today = moscowDate(new Date()); @@ -208,120 +137,12 @@ function draftPeriod(period: IncomePeriod): IncomeForm { return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) }; } -function formatComparisonPeriod(period: IncomePeriod): string { - return `${formatDate(period.from)} – ${formatDate(period.to)}`; -} - -function formatIncomeChange( - difference: string, - compared: string, -): null | string { - // Display-only ratio; server-provided Money values remain exact decimal strings. - const base = Number(compared); - if (!Number.isFinite(base) || base <= 0) - return null; - const ratio = Number(difference) / base; - if (!Number.isFinite(ratio)) - return null; - - return new Intl.NumberFormat("ru-RU", { - maximumFractionDigits: 1, - signDisplay: "exceptZero", - style: "percent", - }).format(ratio); -} - -function isValidPeriod(period: IncomePeriod): period is IncomePeriod { - if (!isDate(period.from) || !isDate(period.to)) return false; - const from = new Date(`${period.from}T00:00:00Z`); - const to = new Date(`${period.to}T00:00:00Z`); - const today = new Date(`${moscowDate(new Date())}T00:00:00Z`); - - return ( - from.toISOString().slice(0, ISO_DATE_LENGTH) === period.from - && to.toISOString().slice(0, ISO_DATE_LENGTH) === period.to - && from <= to - && to <= today - ); -} - -function toIncomeDisplayModel( - analytics: IncomeAnalytics, - meta?: IncomeMeta, -): IncomeDisplayModel | null { - const instruments = new Map< - string, - { - currency: string; - includedCount: number; - receiptCount: number; - } - >(); - - const currencies = new Set( - analytics.totalsByCurrency.map(({ currency }) => currency), - ); - - for (const instrument of analytics.instruments) { - if ( - typeof instrument.key !== "string" - || !instrument.key.trim() - || instruments.has(instrument.key) - || !currencies.has(instrument.total.currency) - ) - - return null; - instruments.set(instrument.key, { - currency: instrument.total.currency, - includedCount: 0, - receiptCount: instrument.receiptCount, - }); - } - - const entries: IncomeDisplayModel["entries"] = []; - const occurrences = new Map(); - - for (const entry of analytics.entries) { - if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim()) - return null; - const instrument = instruments.get(entry.instrumentKey); - if (!instrument || entry.payment?.currency !== instrument.currency) - return null; - const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]); - const occurrence = occurrences.get(occurrenceKey) ?? 0; - occurrences.set(occurrenceKey, occurrence + 1); - instrument.includedCount += 1; - entries.push({ - key: JSON.stringify([ - entry.instrumentKey, - entry.id, - occurrence, - entry.logoName, - ]), - operation: entry, - }); - } - - for (const instrument of instruments.values()) { - if (instrument.includedCount !== instrument.receiptCount) - return null; - } - - const previous = meta?.dependencies?.find(({ role }) => role === "previous"); - const year = meta?.dependencies?.find(({ role }) => role === "year"); - - return { - comparison: comparisonDisplay(analytics.period, analytics.yearComparison), - entries, - previousComparison: comparisonDisplay(analytics.period, analytics.previous), - previousNotice: comparisonNotice(previous, "previousUnavailable"), - yearNotice: comparisonNotice(year, "yearUnavailable"), - }; +function isValidIncomePeriod(period: IncomePeriod): boolean { + return isValidPeriod(period.from, period.to) + && period.to <= moscowDate(new Date()); } export { type AccountIncomePageViewModel, - type IncomeDisplayModel, - toIncomeDisplayModel, useAccountIncomePageViewModel, }; diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx index 91d91d1..219c1f6 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx @@ -13,7 +13,7 @@ import { StateView } from "@wealthledger/ui"; import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { formatSnapshotTime } from "@/shared/lib"; +import { SourceSyncStatus } from "@/shared/ui"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; @@ -47,13 +47,7 @@ const AccountIncomePageLoaded: FC = (props) => { return ( - {!!props.lastSuccessfulAt && ( - - {t("investment.sourceTime", { - date: formatSnapshotTime(props.lastSuccessfulAt), - })} - - )} + {t("income.title")} diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx index 2cd9f14..5b0ceeb 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { formatMoney } from "@/shared/lib"; -import type { IncomeDisplayModel } from "../AccountIncomePage.vm"; +import type { IncomeDisplayModel } from "../../../model/display"; import classes from "../AccountIncomePage.module.css"; diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx index fd85150..ab7507b 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx @@ -5,13 +5,11 @@ import { useTranslation } from "react-i18next"; import { LedgerEntryRow } from "@/entities/broker-operation"; -import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; +import type { IncomeDisplayModel } from "../../../model/display"; import classes from "../AccountIncomePage.module.css"; -type IncomePaymentsProps = { entries: LoadedProps["display"]["entries"] }; - -type LoadedProps = Extract; +type IncomePaymentsProps = { entries: IncomeDisplayModel["entries"] }; const IncomePayments: FC = (props) => { const { t } = useTranslation(); @@ -23,7 +21,7 @@ const IncomePayments: FC = (props) => { {props.entries.map(({ key, operation }) => (
  • - +
  • ))}
    diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.vm.ts b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.vm.ts index 3bf5ae2..199c5c1 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.vm.ts @@ -2,10 +2,11 @@ import { useState } from "react"; import { useController, useFormContext, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { moscowDate } from "@/shared/lib"; + import { formatDraftDate, type IncomeForm, - moscowDate, parseDraftDate, } from "../../../../model/period"; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx index 0663000..ab4f146 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx @@ -11,7 +11,7 @@ import { useLocation, useNavigate, } from "react-router"; -import { describe, expect, it, vi } from "vitest"; +import { assert, describe, expect, it, vi } from "vitest"; import { type BrokerageOperation, @@ -264,8 +264,8 @@ describe("AccountOperationsPage", () => { , ); await screen.findByLabelText("Период"); - expect(searches[0].get("from")).toBeNull(); - expect(searches[0].get("to")).toBeNull(); + expect(searches[0]?.get("from")).toBeNull(); + expect(searches[0]?.get("to")).toBeNull(); for (const expected of [ "?categories=trade", @@ -597,6 +597,8 @@ it("recovers an invalid cursor at the first page without clearing applied filter fireEvent.click(await screen.findByRole("button", { name: "Далее" })); fireEvent.click(await screen.findByRole("button", { name: "Повторить" })); await waitFor(() => expect(searches).toHaveLength(3)); + assert.isDefined(searches[0]); + assert.isDefined(searches[2]); expect(searches[2].get("cursor")).toBeNull(); for (const key of ["from", "to", "categories"]) expect(searches[2].get(key)).toBe(searches[0].get(key)); diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx index b001e41..de98d16 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx @@ -5,10 +5,8 @@ import { AccountOperationsPageError } from "./components/AccountOperationsPageEr import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view"; import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view"; -type AccountOperationsPageProps = { accountId?: string }; - -const AccountOperationsPage: FC = (props) => { - const viewModel = useAccountOperationsPageVm(props.accountId); +const AccountOperationsPage: FC = () => { + const viewModel = useAccountOperationsPageVm(); if (viewModel.state === "loading") { return ; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts index ed1676b..6b9f5fe 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts @@ -1,34 +1,34 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; -import { useParams, useSearchParams } from "react-router"; +import { useState } from "react"; +import { useParams } from "react-router"; import { type BrokerageOperation, brokerageOperationsQueryOptions, operationCategories, type OperationCategory, - useBrokerageOperations, + useBrokerageOperationHistory, } from "@/entities/broker-operation"; import { useScreenSync } from "@/features/tbank-connection"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; -import { isValidPeriod } from "@/shared/lib"; +import { isValidPeriod, useUrlPeriod } from "@/shared/lib"; + +const operationCategorySet = new Set(operationCategories); type AccountOperationsPageViewModel = | { canApply: boolean; draftCategories: OperationCategory[]; - draftFrom: null | string; - draftTo: null | string; + draftPeriod: [null | string, null | string]; hasNext: boolean; hasPrevious: boolean; historyLoading?: boolean; items: BrokerageOperation[]; lastSuccessfulAt?: null | string; onApply: () => void; - onDraftCategoriesChange: (value: OperationCategory[]) => void; - onDraftFromChange: (value: null | string) => void; - onDraftToChange: (value: null | string) => void; + onCategoriesChange: (value: string[]) => void; onNext: () => void; + onPeriodChange: (value: [null | string, null | string]) => void; onPrevious: () => void; onReset: () => void; partial?: boolean; @@ -41,76 +41,31 @@ type AccountOperationsPageViewModel | { onReset: () => void; retry: () => void; state: "error" } | { state: "loading" }; -const useAccountOperationsPageVm = ( - scopeId?: string, -): AccountOperationsPageViewModel => { - const { accountId: routeAccountId } = useParams(); - const accountId = scopeId ?? routeAccountId; +const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { + const { accountId } = useParams(); const queryClient = useQueryClient(); - const [searchParameters, setSearchParameters] = useSearchParams(); - const urlFrom = searchParameters.get("from"); - const urlTo = searchParameters.get("to"); - const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo); - const appliedFrom = isValidUrlPeriod - ? urlFrom - : null; - - const appliedTo = isValidUrlPeriod - ? urlTo - : null; + const { + from: appliedFrom, + parameters: searchParameters, + setParameters: setSearchParameters, + to: appliedTo, + } = useUrlPeriod(); const appliedCategories = operationCategories.filter(category => (searchParameters.get("categories") ?? "").split(",").includes(category), ); - const [draftFrom, setDraftFrom] = useState(appliedFrom); - const [draftTo, setDraftTo] = useState(appliedTo); + const [draftPeriod, setDraftPeriod] = useState<[ + null | string, + null | string, + ]>([appliedFrom, appliedTo]); + + const [draftFrom, draftTo] = draftPeriod; const [draftCategories, setDraftCategories] = useState(appliedCategories); - const scopeKey = JSON.stringify([ - accountId, - urlFrom, - urlTo, - appliedCategories, - ]); - - const [pagination, setPagination] = useState<{ - cursors: string[]; - scopeKey: string; - }>({ cursors: [], scopeKey }); - - const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : []; - const cursor = cursors.at(-1); - - if (pagination.scopeKey !== scopeKey) { - setPagination({ cursors: [], scopeKey }); - } - - useEffect(() => { - if (isValidUrlPeriod) return; - const normalized = new URLSearchParams(searchParameters); - normalized.delete("from"); - normalized.delete("to"); - setSearchParameters(normalized, { replace: true }); - }, [isValidUrlPeriod, searchParameters, setSearchParameters]); - const [previousSearch, setPreviousSearch] = useState(searchParameters); - - if (previousSearch !== searchParameters) { - setPreviousSearch(searchParameters); - setDraftFrom(appliedFrom); - setDraftTo(appliedTo); - setDraftCategories( - operationCategories.filter(category => - (searchParameters.get("categories") ?? "") - .split(",") - .includes(category), - ), - ); - } - const firstPageQuery = { from: dateStart(appliedFrom), to: dateEnd(appliedTo), @@ -118,10 +73,17 @@ const useAccountOperationsPageVm = ( limit: 100, }; - const operations = useBrokerageOperations(accountId, { - ...firstPageQuery, - ...(cursor && { cursor }), - }); + const history = useBrokerageOperationHistory(accountId, firstPageQuery); + const { operations } = history; + const scope = JSON.stringify([accountId, searchParameters.toString()]); + const [previousScope, setPreviousScope] = useState(scope); + + if (previousScope !== scope) { + setPreviousScope(scope); + setDraftPeriod([appliedFrom, appliedTo]); + setDraftCategories(appliedCategories); + history.reset(); + } const sync = useScreenSync( { accountId, screen: "operations", ...firstPageQuery }, @@ -134,31 +96,13 @@ const useAccountOperationsPageVm = ( limit: 100, }).queryKey; - const resetCursors = () => setPagination({ cursors: [], scopeKey }); - - const retry = () => { - if ( - isApiErrorCode( - apiErrorCode(operations.error), - ApiErrorCode.InvalidOperationsCursor, - ) - ) { - void queryClient.invalidateQueries({ - exact: true, - queryKey: brokerageOperationsQueryOptions(accountId, firstPageQuery) - .queryKey, - }); - resetCursors(); - } - else void sync.retry(); - }; + const retry = () => history.retry(sync.retry); const onReset = () => { - setDraftFrom(null); - setDraftTo(null); + setDraftPeriod([null, null]); setDraftCategories([]); setSearchParameters({}); - resetCursors(); + history.reset(); void queryClient.invalidateQueries({ queryKey: resetQueryKey }); }; @@ -182,10 +126,9 @@ const useAccountOperationsPageVm = ( return { canApply: isDirty && isValidPeriod(draftFrom, draftTo), draftCategories, - draftFrom, - draftTo, + draftPeriod, hasNext: Boolean(nextCursor && operations.data.hasNext), - hasPrevious: cursors.length > 0, + hasPrevious: history.hasPrevious, historyLoading: operations.meta?.coverage?.historyState === "loading" || operations.meta?.coverage?.historyState === "partial", @@ -200,17 +143,16 @@ const useAccountOperationsPageVm = ( categories: draftCategories.join(","), }), }); - resetCursors(); + history.reset(); }, - onDraftCategoriesChange: setDraftCategories, - onDraftFromChange: setDraftFrom, - onDraftToChange: setDraftTo, - onNext: () => { - if (nextCursor) - setPagination({ cursors: [...cursors, nextCursor], scopeKey }); - }, - onPrevious: () => - setPagination({ cursors: cursors.slice(0, -1), scopeKey }), + onCategoriesChange: values => setDraftCategories( + values.filter((value): value is OperationCategory => + operationCategorySet.has(value), + ), + ), + onNext: history.onNext, + onPeriodChange: setDraftPeriod, + onPrevious: history.onPrevious, onReset, partial: operations.meta?.availability !== "complete", refreshError: operations.isError || sync.sourceError || sync.transportError, diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx index c981e79..504c644 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx @@ -7,20 +7,17 @@ import { MultiSelect, Progress, Stack, - Text, Title, } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; -import { LedgerEntryRow } from "@/entities/broker-operation"; -import { formatSnapshotTime } from "@/shared/lib"; +import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation"; +import { SourceSyncStatus } from "@/shared/ui"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; -import { useAccountOperationsPageLoadedVm } from "./AccountOperationsPageLoaded.vm"; - type AccountOperationsPageLoadedProps = Extract< AccountOperationsPageViewModel, { state: "loaded" } @@ -56,32 +53,16 @@ const AccountOperationsPageLoadedOperations: FC< const AccountOperationsPageLoaded: FC = ( props, ) => { - const { - canApply, - categoryData, - draftCategories, - draftFrom, - draftTo, - hasNext, - hasPrevious, - onApply, - onCategoriesChange, - onNext, - onPeriodChange, - onPrevious, - onReset, - t, - } = useAccountOperationsPageLoadedVm(props); + const { t } = useTranslation(); + + const categoryData = operationCategories.map(category => ({ + label: t(`ledger.category.${category}`), + value: category, + })); return ( - {!!props.lastSuccessfulAt && ( - - {t("investment.sourceTime", { - date: formatSnapshotTime(props.lastSuccessfulAt), - })} - - )} + {t("ledger.title")} @@ -115,13 +96,13 @@ const AccountOperationsPageLoaded: FC = ( clearable clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }} label={t("ledger.period")} - onChange={onPeriodChange} + onChange={props.onPeriodChange} popoverProps={{ middlewares: { shift: { crossAxis: true } }, returnFocus: true, }} type="range" - value={[draftFrom, draftTo]} + value={props.draftPeriod} valueFormat="DD.MM.YYYY" /> @@ -131,30 +112,30 @@ const AccountOperationsPageLoaded: FC = ( clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }} data={categoryData} label={t("ledger.categories")} - onChange={onCategoriesChange} + onChange={props.onCategoriesChange} placeholder={t("ledger.categoriesPlaceholder")} - value={draftCategories} + value={props.draftCategories} /> - - - {!!(hasNext || hasPrevious) && ( + {!!(props.hasNext || props.hasPrevious) && ( - - diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.vm.ts deleted file mode 100644 index 5ee01cc..0000000 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.vm.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { useTranslation } from "react-i18next"; - -import { - operationCategories, - type OperationCategory, -} from "@/entities/broker-operation"; - -import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; - -type AccountOperationsPageLoadedProps = Extract< - AccountOperationsPageViewModel, - { state: "loaded" } ->; - -const operationCategorySet = new Set(operationCategories); - -function fromMultiSelectValue(value: string[]) { - return value.filter((item): item is OperationCategory => - operationCategorySet.has(item), - ); -} - -function useAccountOperationsPageLoadedVm( - props: AccountOperationsPageLoadedProps, -) { - const { - canApply, - draftCategories, - draftFrom, - draftTo, - hasNext, - hasPrevious, - onApply, - onDraftCategoriesChange, - onDraftFromChange, - onDraftToChange, - onNext, - onPrevious, - onReset, - } = props; - - const { t } = useTranslation(); - - const categoryData = operationCategories.map(category => ({ - label: t(`ledger.category.${category}`), - value: category, - })); - - const onPeriodChange: (value: [null | string, null | string]) => void = ([ - from, - to, - ]) => { - onDraftFromChange(from); - onDraftToChange(to); - }; - - const onCategoriesChange: (value: string[]) => void = value => - onDraftCategoriesChange(fromMultiSelectValue(value)); - - return { - canApply, - categoryData, - draftCategories, - draftFrom, - draftTo, - hasNext, - hasPrevious, - onApply, - onCategoriesChange, - onNext, - onPeriodChange, - onPrevious, - onReset, - t, - }; -} - -export { useAccountOperationsPageLoadedVm }; diff --git a/src/pages/account/model/positions.fixtures.ts b/src/pages/account/model/positions.fixtures.ts new file mode 100644 index 0000000..54508f6 --- /dev/null +++ b/src/pages/account/model/positions.fixtures.ts @@ -0,0 +1,73 @@ +import type { Portfolio } from "@/entities/portfolio"; + +const portfolio + = { + cashBalances: [{ currency: "USD", value: "1" }], + isStale: true, + lastSuccessfulAt: "2026-08-13T10:00:00Z", + positions: [ + { + absoluteReturn: { currency: "RUB", value: "10" }, + averagePurchasePrice: null, + currentValue: { currency: "RUB", value: "100" }, + displayName: "Акция", + figi: "FIGI-STOCK", + isin: null, + logoName: "akciya.png", + quantity: "2", + ticker: null, + type: "STOCK", + }, + { + absoluteReturn: { currency: "RUB", value: "-5" }, + averagePurchasePrice: { currency: "RUB", value: "45" }, + currentValue: { currency: "RUB", value: "50" }, + displayName: "Облигация", + figi: "FIGI-BOND", + isin: "RU000A0JX0J2", + logoName: null, + quantity: "1", + ticker: null, + type: "BOND", + }, + { + absoluteReturn: null, + averagePurchasePrice: null, + currentValue: null, + displayName: "Фонд", + figi: "FIGI-FUND", + isin: null, + logoName: "fond.png", + quantity: "3", + ticker: null, + type: "FUND", + }, + ] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]], + structure: { currencies: [], unvaluedCategories: [] }, + totalsByCurrency: [{ currency: "RUB", value: "150" }], + } satisfies Portfolio; + +const position: Portfolio["positions"][number] = { + ...portfolio.positions[0], + absoluteReturn: { currency: "RUB", value: "25" }, + account: { id: "a", name: "Основной" }, + averagePurchasePrice: { currency: "RUB", value: "280" }, + currentValue: { currency: "RUB", value: "375" }, + displayName: "Сбербанк", + figi: "FIGI-SBER", + isin: "RU0009029540", + logoName: null, + quantity: "1.25", + ticker: "SBER", +}; + +const second = { + ...position, + absoluteReturn: null, + account: { id: "b", name: "ИИС" }, + averagePurchasePrice: null, + currentValue: null, + quantity: "-2", +}; + +export { portfolio, position, second }; diff --git a/src/pages/account/model/positions.test.ts b/src/pages/account/model/positions.test.ts new file mode 100644 index 0000000..b6f905d --- /dev/null +++ b/src/pages/account/model/positions.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; + +import type { Portfolio } from "@/entities/portfolio"; + +import { toAccountPagePositionSections } from "./positions"; +import { portfolio, position, second } from "./positions.fixtures"; + +function sections(positions: Portfolio["positions"]) { + return toAccountPagePositionSections({ + ...portfolio, + positions, + }); +} + +describe("account position sections", () => { + it("excludes currency instruments only from display, preserving order and share denominators", () => { + const otherTypes = [ + "OPTION", + "OTHER", + "FUTURE", + "STRUCTURED_NOTE", + "DIGITAL_FINANCIAL_ASSET", + "FUND", + ] as const; + + const snapshot: Portfolio = { + ...portfolio, + cashBalances: [{ currency: "RUB", value: "1000" }], + positions: [ + ...portfolio.positions, + { + ...portfolio.positions[0], + currentValue: { currency: "RUB", value: "50" }, + figi: "FIGI-CURRENCY", + type: "CURRENCY", + }, + ...otherTypes.map( + + type => + ({ + ...portfolio.positions[2], + currentValue: + type === "OPTION" ? { currency: "RUB", value: "50" } : null, + figi: `FIGI-${type}-NEXT`, + type, + }), + ), + ], + structure: { currencies: [], unvaluedCategories: [] }, + totalsByCurrency: [{ currency: "RUB", value: "1250" }], + }; + + const original = structuredClone(snapshot); + + const sections = toAccountPagePositionSections(snapshot); + + expect(sections.map(section => section.kind)).toEqual([ + "stocks", + "bonds", + "other", + ]); + expect(sections[2]?.groups.map(group => group.positions[0].figi)).toEqual([ + "FIGI-FUND", + ...otherTypes.map(type => `FIGI-${type}-NEXT`), + ]); + expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("40 %"); + expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("20 %"); + expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("20 %"); + expect(snapshot).toEqual(original); + }); + + it("prepares grouped position sections with derived display data", () => { + expect( + toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({ + kind, + positions: groups.flatMap(group => group.positions), + })), + ).toMatchObject([ + { + kind: "stocks", + positions: [ + { + displayName: "Акция", + logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png", + returnText: "+10 ₽", + returnTone: "positive", + shareText: "66,7 %", + }, + ], + + }, + { + kind: "bonds", + positions: [ + { + displayName: "Облигация", + logoSrc: null, + returnText: "-5 ₽", + returnTone: "negative", + shareText: "33,3 %", + }, + ], + }, + { + kind: "other", + positions: [ + { + displayName: "Фонд", + logoSrc: null, + returnText: null, + returnTone: undefined, + shareText: null, + }, + ], + }, + ]); + }); + + it("uses ISIN only when FIGI is absent, never merging by name or ticker", () => { + expect( + + sections([ + { + ...position, + figi: null, + }, + { ...second, figi: null }, + ])[0]?.groups, + ).toHaveLength(1); + expect( + sections([position, { ...second, figi: "OTHER-FIGI" }])[0]?.groups, + ).toHaveLength(2); + expect( + + sections([ + { + ...position, + figi: null, + isin: null, + }, + { + ...second, + figi: null, + isin: null, + }, + ])[0]?.groups, + ).toHaveLength(2); + }); + + it("preserves one-account cards and does not merge repeated rows on the same account", () => { + expect( + sections([position, { ...second, account: position.account }])[0]?.groups, + ).toHaveLength(2); + expect( + sections([ + { ...position, account: undefined }, + { ...second, account: undefined }, + ])[0]?.groups, + ).toHaveLength(2); + }); + + it("keeps currencies and purchase prices attached to their source positions", () => { + const source = [ + position, + { + ...second, + averagePurchasePrice: { currency: "USD", value: "0.99" }, + currentValue: { currency: "USD", value: "1.01" }, + }, + ]; + + const before = structuredClone(source); + const group = sections(source)[0]?.groups[0]; + expect( + group?.positions.map(item => [ + item.quantity, + item.currentValue, + item.averagePurchasePrice, + ]), + ).toEqual( + source.map(item => [ + item.quantity, + item.currentValue, + item.averagePurchasePrice, + ]), + ); + expect(source).toEqual(before); + }); +}); diff --git a/src/pages/account/model/positions.ts b/src/pages/account/model/positions.ts new file mode 100644 index 0000000..63414f3 --- /dev/null +++ b/src/pages/account/model/positions.ts @@ -0,0 +1,170 @@ +import { formatShare, type Portfolio } from "@/entities/portfolio"; +import { formatMoney, instrumentLogoUrl } from "@/shared/lib"; + +type AccountPagePosition = Portfolio["positions"][number] & { + instrumentTo?: string; + logoSrc: null | string; + returnText: null | string; + returnTone?: AccountPageReturnTone; + shareText: null | string; +}; + +type AccountPagePositionGroup = { + key: string; + positions: [AccountPagePosition, ...AccountPagePosition[]]; +}; + +type AccountPagePositionSection = { + groups: AccountPagePositionGroup[]; + kind: PositionSectionKind; +}; + +type AccountPageReturnTone = "negative" | "positive"; + +type PositionSectionKind = "bonds" + | "other" + | "stocks"; + +function groupPositions( + positions: AccountPagePosition[], +): AccountPagePositionGroup[] { + const groups = new Map(); + + for (const [index, position] of positions.entries()) { + const identity = positionIdentity(position); + const rowKey = JSON.stringify(["row", position.account?.id, index]); + + const key + = identity && position.account + ? JSON.stringify([position.type, ...identity]) + : rowKey; + + const group = groups.get(key); + + if ( + group + && group.positions.every( + item => item.account?.id !== position.account?.id, + ) + ) { + group.positions.push(position); + } + else { + const uniqueKey = group ? rowKey : key; + groups.set(uniqueKey, { key: uniqueKey, positions: [position] }); + } + } + + // eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline. + return [...groups.values()]; +} + +function positionIdentity(position: AccountPagePosition) { + if (position.instrumentUid?.trim()) + return ["uid", position.instrumentUid.trim()]; + if (position.figi?.trim()) return ["figi", position.figi.trim()]; + + return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined; +} + +function toAccountPagePositionSections( + portfolio: Portfolio, +): AccountPagePositionSection[] { + const denominators = new Map(); + + for (const position of portfolio.positions) { + if (!position.currentValue) continue; + const currency = position.currentValue.currency; + denominators.set( + currency, + (denominators.get(currency) ?? 0) + Number(position.currentValue.value), + ); + } + + const stocks = toSection( + "stocks", + portfolio, + denominators, + ); + + const bonds = toSection("bonds", portfolio, denominators); + const other = toSection("other", portfolio, denominators); + + return [stocks, bonds, other].filter(section => section.groups.length > 0); +} + +function toPosition( + position: Portfolio["positions"][number], + denominators: Map, + kind: PositionSectionKind, +): AccountPagePosition { + const isExpanded = kind !== "other"; + + const returnValue = position.absoluteReturn + ? Number(position.absoluteReturn.value) + : null; + + let returnTone: AccountPageReturnTone | undefined; + if (returnValue !== null && returnValue !== 0) + returnTone = returnValue > 0 ? "positive" : "negative"; + + const returnPrefix + = isExpanded && returnValue !== null && returnValue > 0 ? "+" : ""; + + return { + ...position, + instrumentTo: + (kind === "stocks" || kind === "bonds") && position.instrumentUid + ? `/${kind}/${encodeURIComponent(position.instrumentUid)}` + : undefined, + logoSrc: isExpanded + ? instrumentLogoUrl(position.logoName) + : null, + returnText: position.absoluteReturn + ? `${returnPrefix}${formatMoney(position.absoluteReturn)}` + : null, + returnTone, + shareText: position.currentValue?.currency + ? formatShare(position.currentValue, { + currency: position.currentValue.currency, + value: String(denominators.get(position.currentValue.currency) ?? 0), + }) + : null, + }; +} + +function toSection( + kind: PositionSectionKind, + portfolio: Portfolio, + denominators: Map, +): AccountPagePositionSection { + return { + groups: groupPositions( + portfolio.positions + .filter((position) => { + if (kind === "stocks") return position.type === "STOCK"; + + return kind === "bonds" + ? position.type === "BOND" + : position.type !== "STOCK" + && position.type !== "BOND" + && position.type !== "CURRENCY"; + }) + .map(position => + toPosition( + position, + denominators, + kind, + ), + ), + ), + kind, + }; +} + +export { + type AccountPagePosition, + type AccountPagePositionGroup, + type AccountPagePositionSection, + toAccountPagePositionSections, +}; diff --git a/src/pages/account/model/previews.ts b/src/pages/account/model/previews.ts new file mode 100644 index 0000000..7390268 --- /dev/null +++ b/src/pages/account/model/previews.ts @@ -0,0 +1,144 @@ +import type { BrokerageOperation } from "@/entities/broker-operation"; +import type { + BrokerageEvent, + BrokerageEventsPreviewState, +} from "@/entities/brokerage-event"; +import type { IncomeSummary } from "@/entities/income"; +import type { InvestmentResourceMeta } from "@/shared/api"; + +const RECENT_EVENTS_LIMIT = 5; + +type AccountPageIncomeSummary + = | { + lastSuccessfulAt?: null | string; + refreshError?: boolean; + stale?: boolean; + state: "loaded"; + summary: IncomeSummary; + to: string; + } + | { retry: () => Promise | undefined; state: "error"; to: string } + | { + state: "empty" + | "loading" + | "partial"; + to: string; + }; + +type AccountPageRecentEvents = BrokerageEventsPreviewState; + +type AccountPageRecentOperations + = | { + items: BrokerageOperation[]; + lastSuccessfulAt?: null | string; + refreshError?: boolean; + stale?: boolean; + state: "loaded" | "partial"; + to: string; + } + | { + state: "empty" + | "error" + | "loading"; + to: string; + }; + +type PreviewResource = { + data?: T; + isError: boolean; + meta?: Pick; +}; + +function toIncomeSummary( + accountId: string | undefined, + income: PreviewResource, + retry: () => Promise, +): AccountPageIncomeSummary { + const to = `/accounts/${accountId}/income`; + if (!income.data && !income.isError && income.meta?.sync.state !== "failed") + return { state: "loading", to }; + if (income.meta?.availability === "partial") return { state: "partial", to }; + if (!income.data && (income.isError || income.meta?.sync.state === "failed")) + return { retry, state: "error", to }; + if (!income.data?.totalsByCurrency.length) return { state: "empty", to }; + + return { + lastSuccessfulAt: income.meta?.lastSuccessfulAt, + refreshError: income.isError || income.meta?.sync.state === "failed", + stale: income.meta?.freshness === "stale", + state: "loaded", + summary: income.data, + to, + }; +} + +function toRecentEvents( + accountId: string | undefined, + events: PreviewResource<{ events: BrokerageEvent[] }>, +): AccountPageRecentEvents { + const to = `/accounts/${accountId}/events`; + if (!events.data && !events.isError && events.meta?.sync.state !== "failed") + return { state: "loading", to }; + if (events.meta?.availability === "partial") + return { + items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [], + state: "partial", + to, + }; + if (!events.data && (events.isError || events.meta?.sync.state === "failed")) + return { state: "error", to }; + const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? []; + if (items.length === 0) return { state: "empty", to }; + + return { + items, + lastSuccessfulAt: events.meta?.lastSuccessfulAt, + refreshError: events.isError || events.meta?.sync.state === "failed", + stale: events.meta?.freshness === "stale", + state: "loaded", + to, + }; +} + +function toRecentOperations( + accountId: string | undefined, + operations: PreviewResource<{ items: BrokerageOperation[] }>, +): AccountPageRecentOperations { + const to = `/accounts/${accountId}/operations`; + if ( + !operations.data + && !operations.isError + && operations.meta?.sync.state !== "failed" + ) + return { state: "loading", to }; + if (operations.meta?.availability === "partial") + return { items: operations.data?.items ?? [], state: "partial", to }; + if ( + !operations.data + && (operations.isError || operations.meta?.sync.state === "failed") + ) + return { state: "error", to }; + if (!operations.data?.items.length) return { state: "empty", to }; + + return { + items: operations.data.items, + lastSuccessfulAt: operations.meta?.lastSuccessfulAt, + refreshError: + operations.isError || operations.meta?.sync.state === "failed", + stale: operations.meta?.freshness === "stale", + state: "loaded", + to, + }; +} + +export { toIncomeSummary, toRecentEvents, toRecentOperations }; +export type { + AccountPageIncomeSummary, + AccountPageRecentEvents, + AccountPageRecentOperations, +}; diff --git a/src/pages/account/ui/AccountPage/AccountPage.module.css b/src/pages/account/ui/AccountPage/AccountPage.module.css index c097c31..9c0e130 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.module.css +++ b/src/pages/account/ui/AccountPage/AccountPage.module.css @@ -1,5 +1,4 @@ -.tile, -.cash { +.tile { border-color: var(--mantine-color-gray-3); background: var(--mantine-color-gray-0); } @@ -52,15 +51,10 @@ margin-block: 5px; } -.tile, -.cash { +.tile { padding: var(--mantine-spacing-md); } -.cash { - display: flex; - justify-content: space-between; -} .positive { color: var(--wl-color-positive); diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 1492a67..1a8a691 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -22,10 +22,10 @@ import { server } from "@/test/server"; import type { AccountPageRecentEvents, AccountPageRecentOperations, -} from "./AccountPage.vm"; +} from "../../model/previews"; +import { toAccountPagePositionSections } from "../../model/positions"; import { AccountPage } from "./AccountPage"; -import { toAccountPagePositionSections } from "./AccountPage.vm"; import { AccountPageEmpty } from "./components/AccountPageEmpty.view"; import { AccountPageError } from "./components/AccountPageError.view"; import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; @@ -39,13 +39,13 @@ function configureTestAuth() { }); } -function renderAccountRoute(queryClient: QueryClient) { +function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") { configureTestAuth(); return render( - + } path="/accounts/:accountId" /> @@ -67,7 +67,7 @@ function renderPage(ui: React.ReactNode) { ); } -const portfolio: Portfolio +const portfolio = { cashBalances: [{ currency: "USD", value: "0.5" }], isStale: true, @@ -97,10 +97,10 @@ const portfolio: Portfolio ticker: null, type: "BOND", }, - ], + ] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]], structure: { currencies: [], unvaluedCategories: [] }, totalsByCurrency: [{ currency: "RUB", value: "1234.5" }], - }; + } satisfies Portfolio; function position( overrides: Partial, @@ -202,7 +202,7 @@ describe("AccountPage states", () => { }), ); configureTestAuth(); - renderPage(); + renderAccountRoute(createTestQueryClient(), accountId); await waitFor(() => { const links = screen.getAllByRole("link", { name: position.displayName, @@ -212,10 +212,10 @@ describe("AccountPage states", () => { for (const link of links) expect(link).toHaveAttribute( "href", - `/${collection}/${type === "STOCK" ? slug : uid}`, + `/${collection}/${uid}`, ); }); - expect(requests).toHaveBeenCalledTimes(type === "STOCK" ? 1 : 0); + expect(requests).not.toHaveBeenCalled(); expect( screen.queryByText("Название из карточки инструмента"), ).not.toBeInTheDocument(); @@ -234,43 +234,6 @@ describe("AccountPage states", () => { }, ); - it.each(["unavailable", "noncanonical"])( - "keeps exact UID links and the portfolio visible when ticker is %s", - async (scenario) => { - const uid = "c190ff1f-1447-4227-b543-316332699ca5"; - const requests = vi.fn(); - server.use( - http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => - HttpResponse.json( - resource({ - ...portfolio, - positions: [ - { - ...portfolio.positions[0], - instrumentUid: uid, - ticker: "SBERP", - }, - ], - }), - ), - ), - http.get("*/api/v1/stocks/:uid", () => { - requests(); - - return scenario === "unavailable" - ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) - : HttpResponse.json(resource({ instrumentUid: uid, slug: uid })); - }), - ); - renderAccountRoute(createTestQueryClient()); - await waitFor(() => expect(requests).toHaveBeenCalledOnce()); - const links = await screen.findAllByRole("link", { name: "Вторая" }); - expect(links).toHaveLength(2); - for (const link of links) - expect(link).toHaveAttribute("href", `/stocks/${uid}`); - }, - ); - it("keeps the account and positions visible for an older HTTP response without structure", async () => { server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => @@ -302,7 +265,9 @@ describe("AccountPage states", () => { expect( screen.queryByRole("button", { name: "Повторить" }), ).not.toBeInTheDocument(); - expect(screen.getByRole("link")).toHaveAttribute( + expect(screen.getByRole("link", { + name: code === "NOT_FOUND" ? "К счетам" : "Открыть настройки T-Bank", + })).toHaveAttribute( "href", code === "NOT_FOUND" ? "/accounts" : "/settings", ); @@ -427,34 +392,13 @@ describe("AccountPage states", () => { }, ); - it("renders loading and empty states", () => { - renderPage(); - expect(screen.getByRole("status")).toBeInTheDocument(); - + it("renders the empty state", () => { renderPage(); expect( screen.getByRole("heading", { name: "Портфель пока пуст" }), ).toBeInTheDocument(); }); - it("renders allowed error actions", () => { - const retry = vi.fn(); - renderPage(); - expect(screen.getByRole("link", { name: "К счетам" })).toHaveAttribute( - "href", - "/accounts", - ); - - renderPage(); - expect( - screen.getByRole("link", { name: "Открыть настройки T-Bank" }), - ).toHaveAttribute("href", "/settings"); - - renderPage(); - fireEvent.click(screen.getByRole("button", { name: "Повторить" })); - expect(retry).toHaveBeenCalledOnce(); - }); - it("renders a stale portfolio in upstream order with nullable values hidden", () => { renderPage(); diff --git a/src/pages/account/ui/AccountPage/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx index 4512daf..edf4835 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.tsx @@ -6,10 +6,8 @@ import { AccountPageError } from "./components/AccountPageError.view"; import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view"; -type AccountPageProps = { accountId?: string }; - -const AccountPage: FC = (props) => { - const viewModel = useAccountPageViewModel(props.accountId); +const AccountPage: FC = () => { + const viewModel = useAccountPageViewModel(); if (viewModel.state === "loading") return ; if (viewModel.state === "error") return ; if (viewModel.state === "empty") diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx index f22b3ca..091d099 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx @@ -6,64 +6,13 @@ import { http, HttpResponse } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; import { describe, expect, it, vi } from "vitest"; -import type { Portfolio } from "@/entities/portfolio"; - import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; -import { - toAccountPagePositionSections, - useAccountPageViewModel, -} from "./AccountPage.vm"; - -const portfolio: Portfolio - = { - cashBalances: [{ currency: "USD", value: "1" }], - isStale: true, - lastSuccessfulAt: "2026-08-13T10:00:00Z", - positions: [ - { - absoluteReturn: { currency: "RUB", value: "10" }, - averagePurchasePrice: null, - currentValue: { currency: "RUB", value: "100" }, - displayName: "Акция", - figi: "FIGI-STOCK", - isin: null, - logoName: "akciya.png", - quantity: "2", - ticker: null, - type: "STOCK", - }, - { - absoluteReturn: { currency: "RUB", value: "-5" }, - averagePurchasePrice: { currency: "RUB", value: "45" }, - currentValue: { currency: "RUB", value: "50" }, - displayName: "Облигация", - figi: "FIGI-BOND", - isin: "RU000A0JX0J2", - logoName: null, - quantity: "1", - ticker: null, - type: "BOND", - }, - { - absoluteReturn: null, - averagePurchasePrice: null, - currentValue: null, - displayName: "Фонд", - figi: "FIGI-FUND", - isin: null, - logoName: "fond.png", - quantity: "3", - ticker: null, - type: "FUND", - }, - ], - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [{ currency: "RUB", value: "150" }], - }; +import { portfolio } from "../../model/positions.fixtures"; +import { useAccountPageViewModel } from "./AccountPage.vm"; function configureTestAuth() { configureAuthClient({ @@ -154,123 +103,6 @@ function mockLoadedAccountPage() { } describe("useAccountPageViewModel", () => { - it("excludes currency instruments only from display, preserving order and share denominators", () => { - const otherTypes = [ - "OPTION", - "OTHER", - "FUTURE", - "STRUCTURED_NOTE", - "DIGITAL_FINANCIAL_ASSET", - "FUND", - ] as const; - - const snapshot: Portfolio = { - ...portfolio, - cashBalances: [{ currency: "RUB", value: "1000" }], - positions: [ - ...portfolio.positions, - { - ...portfolio.positions[0], - currentValue: { currency: "RUB", value: "50" }, - figi: "FIGI-CURRENCY", - type: "CURRENCY", - }, - ...otherTypes.map( - - type => - ({ - ...portfolio.positions[2], - currentValue: - type === "OPTION" ? { currency: "RUB", value: "50" } : null, - figi: `FIGI-${type}-NEXT`, - type, - }), - ), - ], - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [{ currency: "RUB", value: "1250" }], - }; - - const original = structuredClone(snapshot); - - const sections = toAccountPagePositionSections(snapshot); - - expect(sections.map(section => section.kind)).toEqual([ - "stocks", - "bonds", - "other", - ]); - expect(sections[2].groups.map(group => group.positions[0].figi)).toEqual([ - "FIGI-FUND", - ...otherTypes.map(type => `FIGI-${type}-NEXT`), - ]); - expect(sections[0].groups[0].positions[0].shareText).toBe("40 %"); - expect(sections[1].groups[0].positions[0].shareText).toBe("20 %"); - expect(sections[2].groups[1].positions[0].shareText).toBe("20 %"); - expect(snapshot).toEqual(original); - }); - - it("prepares grouped position sections with derived display data", async () => { - configureTestAuth(); - mockLoadedAccountPage(); - - const { result } = renderHook(() => useAccountPageViewModel(), { - wrapper: createWrapper(), - }); - - await waitFor(() => expect(result.current.state).toBe("loaded")); - - const loaded = result.current as Extract< - typeof result.current, - { state: "loaded" } - >; - - expect( - loaded.positionSections.map(({ groups, kind }) => ({ - kind, - positions: groups.flatMap(group => group.positions), - })), - ).toMatchObject([ - { - kind: "stocks", - positions: [ - { - displayName: "Акция", - logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png", - returnText: "+10 ₽", - returnTone: "positive", - shareText: "66,7 %", - }, - ], - - }, - { - kind: "bonds", - positions: [ - { - displayName: "Облигация", - logoSrc: null, - returnText: "-5 ₽", - returnTone: "negative", - shareText: "33,3 %", - }, - ], - }, - { - kind: "other", - positions: [ - { - displayName: "Фонд", - logoSrc: null, - returnText: null, - returnTone: undefined, - shareText: null, - }, - ], - }, - ]); - }); - it("prepares recent operations and events state for presentation views", async () => { configureTestAuth(); mockLoadedAccountPage(); diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 32868b7..77c0341 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -1,6 +1,5 @@ import { useParams } from "react-router"; -import type { BrokerageOperation } from "@/entities/broker-operation"; import type { Portfolio } from "@/entities/portfolio"; import { useBrokerageOperations } from "@/entities/broker-operation"; @@ -9,17 +8,25 @@ import { useBrokerageAccounts, } from "@/entities/brokerage-account"; import { - type BrokerageEventsPreviewState, useBrokerageEvents, } from "@/entities/brokerage-event"; -import { type IncomeSummary, useIncomeSummary } from "@/entities/income"; -import { formatShare, usePortfolio } from "@/entities/portfolio"; -import { useStocks } from "@/entities/stock"; +import { useIncomeSummary } from "@/entities/income"; +import { usePortfolio } from "@/entities/portfolio"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; -import { formatMoney, instrumentLogoUrl } from "@/shared/lib"; -const RECENT_EVENTS_LIMIT = 5; +import { + type AccountPagePositionSection, + toAccountPagePositionSections, +} from "../../model/positions"; +import { + type AccountPageIncomeSummary, + type AccountPageRecentEvents, + type AccountPageRecentOperations, + toIncomeSummary, + toRecentEvents, + toRecentOperations, +} from "../../model/previews"; type AccountPageContent = { account?: BrokerageAccount; @@ -38,314 +45,22 @@ type AccountPageEmptyViewModel = AccountPageContent & { state: "empty"; }; -type AccountPageIncomeSummary - = | { - lastSuccessfulAt?: null | string; - refreshError?: boolean; - stale?: boolean; - state: "loaded"; - summary: IncomeSummary; - to: string; - } - | { retry: () => Promise | undefined; state: "error"; to: string } - | { state: "empty" - | "loading" - | "partial"; to: string; }; - type AccountPageLoadedViewModel = AccountPageContent & { portfolio: Portfolio; positionSections: AccountPagePositionSection[]; state: "loaded"; }; -type AccountPagePosition = Portfolio["positions"][number] & { - instrumentTo?: string; - logoSrc: null | string; - returnText: null | string; - returnTone?: AccountPageReturnTone; - shareText: null | string; -}; - -type AccountPagePositionGroup = { - key: string; - positions: AccountPagePosition[]; -}; - -type AccountPagePositionSection = { - groups: AccountPagePositionGroup[]; - kind: PositionSectionKind; -}; - -type AccountPageRecentEvents = BrokerageEventsPreviewState; - -type AccountPageRecentOperations - = | { - items: BrokerageOperation[]; - lastSuccessfulAt?: null | string; - refreshError?: boolean; - stale?: boolean; - state: "loaded" | "partial"; - to: string; - } - | { - state: "empty" - | "error" - | "loading"; - to: string; - }; - -type AccountPageReturnTone = "negative" | "positive"; - type AccountPageViewModel = | AccountPageEmptyViewModel | AccountPageLoadedViewModel | { code?: string; retry: () => Promise | undefined; state: "error" } | { state: "loading" }; -type PositionSectionKind = "bonds" - | "other" - | "stocks"; - -function groupPositions( - positions: AccountPagePosition[], -): AccountPagePositionGroup[] { - const groups = new Map(); - - for (const [index, position] of positions.entries()) { - const identity = positionIdentity(position); - const rowKey = JSON.stringify(["row", position.account?.id, index]); - - const key - = identity && position.account - ? JSON.stringify([position.type, ...identity]) - : rowKey; - - const group = groups.get(key); - - if ( - group - && group.positions.every( - item => item.account?.id !== position.account?.id, - ) - ) { - group.positions.push(position); - } - else { - const uniqueKey = group ? rowKey : key; - groups.set(uniqueKey, { key: uniqueKey, positions: [position] }); - } - } - - // eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline. - return [...groups.values()]; -} - -function positionIdentity(position: AccountPagePosition) { - if (position.instrumentUid?.trim()) - return ["uid", position.instrumentUid.trim()]; - if (position.figi?.trim()) return ["figi", position.figi.trim()]; - - return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined; -} - -function toAccountPagePositionSections( - portfolio: Portfolio, - instrumentDetails: Map = new Map(), -): AccountPagePositionSection[] { - const denominators = new Map(); - - for (const position of portfolio.positions) { - if (!position.currentValue) continue; - const currency = position.currentValue.currency; - denominators.set( - currency, - (denominators.get(currency) ?? 0) + Number(position.currentValue.value), - ); - } - - const stocks = toSection( - "stocks", - portfolio, - denominators, - instrumentDetails, - ); - - const bonds = toSection("bonds", portfolio, denominators, instrumentDetails); - const other = toSection("other", portfolio, denominators, instrumentDetails); - - return [stocks, bonds, other].filter(section => section.groups.length > 0); -} - -function toIncomeSummary( - accountId: string | undefined, - income: ReturnType, -): AccountPageIncomeSummary { - const to = `/accounts/${accountId}/income`; - if (!income.data && !income.isError && income.meta?.sync.state !== "failed") - return { state: "loading", to }; - if (income.meta?.availability === "partial") return { state: "partial", to }; - if (!income.data && (income.isError || income.meta?.sync.state === "failed")) - return { retry: () => income.refetch(), state: "error", to }; - if (!income.data?.totalsByCurrency.length) return { state: "empty", to }; - - return { - lastSuccessfulAt: income.meta?.lastSuccessfulAt, - refreshError: income.isError || income.meta?.sync.state === "failed", - stale: income.meta?.freshness === "stale", - state: "loaded", - summary: income.data, - to, - }; -} - -function toPosition( - position: Portfolio["positions"][number], - denominators: Map, - kind: PositionSectionKind, - instrumentSlug?: string, -): AccountPagePosition { - const isExpanded = kind !== "other"; - - const returnValue = position.absoluteReturn - ? Number(position.absoluteReturn.value) - : null; - - let returnTone: AccountPageReturnTone | undefined; - if (returnValue !== null && returnValue !== 0) - returnTone = returnValue > 0 ? "positive" : "negative"; - - const returnPrefix - = isExpanded && returnValue !== null && returnValue > 0 ? "+" : ""; - - return { - ...position, - instrumentTo: - (kind === "stocks" || kind === "bonds") && position.instrumentUid - ? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}` - : undefined, - logoSrc: isExpanded - ? instrumentLogoUrl(position.logoName) - : null, - returnText: position.absoluteReturn - ? `${returnPrefix}${formatMoney(position.absoluteReturn)}` - : null, - returnTone, - shareText: position.currentValue?.currency - ? formatShare(position.currentValue, { - currency: position.currentValue.currency, - value: String(denominators.get(position.currentValue.currency) ?? 0), - }) - : null, - }; -} - -function toRecentEvents( - accountId: string | undefined, - events: ReturnType, -): AccountPageRecentEvents { - const to = `/accounts/${accountId}/events`; - if (!events.data && !events.isError && events.meta?.sync.state !== "failed") - return { state: "loading", to }; - if (events.meta?.availability === "partial") - return { - items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [], - state: "partial", - to, - }; - if (!events.data && (events.isError || events.meta?.sync.state === "failed")) - return { state: "error", to }; - const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? []; - if (items.length === 0) return { state: "empty", to }; - - return { - items, - lastSuccessfulAt: events.meta?.lastSuccessfulAt, - refreshError: events.isError || events.meta?.sync.state === "failed", - stale: events.meta?.freshness === "stale", - state: "loaded", - to, - }; -} - -function toRecentOperations( - accountId: string | undefined, - operations: ReturnType, -): AccountPageRecentOperations { - const to = `/accounts/${accountId}/operations`; - if ( - !operations.data - && !operations.isError - && operations.meta?.sync.state !== "failed" - ) - return { state: "loading", to }; - if (operations.meta?.availability === "partial") - return { items: operations.data?.items ?? [], state: "partial", to }; - if ( - !operations.data - && (operations.isError || operations.meta?.sync.state === "failed") - ) - return { state: "error", to }; - if (!operations.data?.items.length) return { state: "empty", to }; - - return { - items: operations.data.items, - lastSuccessfulAt: operations.meta?.lastSuccessfulAt, - refreshError: - operations.isError || operations.meta?.sync.state === "failed", - stale: operations.meta?.freshness === "stale", - state: "loaded", - to, - }; -} - -function toSection( - kind: PositionSectionKind, - portfolio: Portfolio, - denominators: Map, - instrumentDetails: Map, -): AccountPagePositionSection { - return { - groups: groupPositions( - portfolio.positions - .filter((position) => { - if (kind === "stocks") return position.type === "STOCK"; - - return kind === "bonds" - ? position.type === "BOND" - : position.type !== "STOCK" - && position.type !== "BOND" - && position.type !== "CURRENCY"; - }) - .map(position => - toPosition( - position, - denominators, - kind, - instrumentDetails.get(position.instrumentUid ?? "")?.slug, - ), - ), - ), - kind, - }; -} - -function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { - const { accountId: routeAccountId } = useParams(); - const accountId = scopeId ?? routeAccountId; +function useAccountPageViewModel(): AccountPageViewModel { + const { accountId } = useParams(); const portfolio = usePortfolio(accountId); - const stocks = useStocks( - portfolio.data?.positions.flatMap(position => - position.type === "STOCK" && position.instrumentUid - ? [position.instrumentUid] - : [], - ) ?? [], - ); - - const instrumentDetails = stocks; - const operations = useBrokerageOperations(accountId, { limit: 5, }); @@ -376,7 +91,10 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { const account = accounts.data?.find(item => item.id === accountId); const recentOperations = toRecentOperations(accountId, operations); const recentEvents = toRecentEvents(accountId, events); - const incomeSummary = toIncomeSummary(accountId, income); + + const incomeSummary = toIncomeSummary( + accountId, income, () => income.refetch(), + ); const refresh = { partial: portfolio.meta?.availability === "partial", @@ -406,10 +124,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { accountId, incomeSummary, portfolio: portfolio.data, - positionSections: toAccountPagePositionSections( - portfolio.data, - instrumentDetails, - ), + positionSections: toAccountPagePositionSections(portfolio.data), recentEvents, recentOperations, state: "loaded" as const, @@ -417,13 +132,4 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { }; } -export { - type AccountPageIncomeSummary, - type AccountPagePosition, - type AccountPagePositionGroup, - type AccountPagePositionSection, - type AccountPageRecentEvents, - type AccountPageRecentOperations, - toAccountPagePositionSections, - useAccountPageViewModel, -}; +export { useAccountPageViewModel }; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx index bcea919..1d3a28b 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx @@ -7,16 +7,16 @@ import { useTranslation } from "react-i18next"; import type { BrokerageAccount } from "@/entities/brokerage-account"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; +import { InvestmentRefreshState } from "@/features/tbank-connection"; import type { AccountPageIncomeSummary, AccountPageRecentEvents, AccountPageRecentOperations, -} from "../AccountPage.vm"; +} from "../../../model/previews"; import { AccountHeading } from "./AccountHeading.view"; import { IncomeSummary } from "./IncomeSummary.view"; -import { PortfolioRefreshState } from "./PortfolioRefreshState/PortfolioRefreshState.view"; import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure"; import { RecentOperations } from "./RecentOperations.view"; @@ -45,7 +45,8 @@ const AccountPageEmpty: FC = (props) => { - = (props) => { - { - it.each([0, 1, 3, 5])( + it.each([0, 5])( "renders only the %i supplied totals in upstream order", (count) => { const totals = [ @@ -74,20 +74,4 @@ describe("CurrencyCards currency presentation", () => { ); }, ); - - it("keeps USD and CAD as distinct supplied values", () => { - render( - - - , - ); - expect( - screen.getAllByRole("definition").map(node => node.textContent), - ).toEqual(["1,00 $", "2,00 CAD"]); - }); }); diff --git a/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx b/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx index 9f4af99..7a7afcf 100644 --- a/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx +++ b/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx @@ -7,7 +7,8 @@ import { Link } from "react-router"; import type { IncomeSummary as IncomeSummaryData } from "@/entities/income"; -import { formatMoney, formatSnapshotTime } from "@/shared/lib"; +import { formatMoney } from "@/shared/lib"; +import { SourceSyncStatus } from "@/shared/ui"; type IncomeSummaryProps = | { @@ -25,6 +26,12 @@ type IncomeSummaryProps const IncomeSummary: FC = (props) => { const { t } = useTranslation(); + + const errorText + = props.state === "loaded" && props.refreshError + ? t("income.refreshError") + : null; + if (props.state === "loading" || props.state === "partial") return ( @@ -59,17 +66,11 @@ const IncomeSummary: FC = (props) => { return ( - {!!props.lastSuccessfulAt && ( - - {t("investment.sourceTime", { - date: formatSnapshotTime(props.lastSuccessfulAt), - })} - - )} - - {!!props.stale && {t("investment.stale")}} - - {!!props.refreshError && {t("income.refreshError")}} + {props.summary.totalsByCurrency.map(total => ( diff --git a/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories.tsx b/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories.tsx deleted file mode 100644 index 212a8e8..0000000 --- a/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; - -import { Stack } from "@mantine/core"; -import { fn } from "storybook/test"; - -import { CurrencyCards } from "../CurrencyCards/CurrencyCards.view"; -import { PortfolioRefreshState } from "./PortfolioRefreshState.view"; - -const meta = { - args: { refreshError: false, refreshing: false, retry: fn() }, - argTypes: { - refreshError: { control: "boolean" }, - refreshing: { control: "boolean" }, - retry: { control: false }, - }, - component: PortfolioRefreshState, - parameters: { - docs: { - description: { - component: - "Обновление уже показанных данных портфеля. Реальные CurrencyCards остаются видимыми вместе с PortfolioRefreshState при refetch и его ошибке. Суммы синтетические; это presentation Client Cached Snapshot, без QueryClient и запроса API. Retry регистрирует локальное действие в Actions и не доказывает восстановление API.", - }, - }, - }, - render: arguments_ => ( - - - - - - ), - tags: ["autodocs"], - title: "Product/PortfolioRefreshState", -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Idle: Story = { name: "Сохранённые данные" }; -export const Refreshing: Story = { - args: { refreshing: true }, - name: "Фоновое обновление", -}; -export const RefreshError: Story = { - args: { refreshError: true }, - name: "Ошибка обновления с retry", -}; - -export const SourceFailure: Story = { - args: { refreshError: true, sourceError: true }, - name: "Ошибка синхронизации сохраняет суммы", -}; diff --git a/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx b/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx deleted file mode 100644 index 1507457..0000000 --- a/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { FC } from "react"; - -import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core"; -import { useTranslation } from "react-i18next"; - -type PortfolioRefreshStateProps = { - refreshError: boolean; - refreshing: boolean; - retry?: () => void; - sourceError?: boolean; -}; - -const PortfolioRefreshState: FC = (props) => { - const { refreshError, refreshing, retry } = props; - const { t } = useTranslation(); - - return ( - <> - - - {!!refreshing && ( - - - - - {t("portfolio.refreshing")} - - - )} - - {!!refreshError && ( - - - - {t( - props.sourceError - ? "investment.syncFailed" - : "portfolio.refreshError", - )} - - - - )} - - ); -}; - -export { PortfolioRefreshState }; diff --git a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx index 126619d..526694e 100644 --- a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { AccountPagePosition } from "../../AccountPage.vm"; +import type { AccountPagePosition } from "../../../../model/positions"; import { PositionCard } from "./PositionCard.view"; diff --git a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx index d2062ea..a83205f 100644 --- a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx @@ -6,10 +6,7 @@ import { Link } from "react-router"; import { formatMoney } from "@/shared/lib"; -import type { - AccountPagePosition, - AccountPagePositionSection, -} from "../../AccountPage.vm"; +import type { AccountPagePosition, AccountPagePositionSection } from "../../../../model/positions"; import classes from "../../AccountPage.module.css"; import { PositionLogo } from "../PositionLogo.view"; diff --git a/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx b/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx index 32da3a9..25e0ddd 100644 --- a/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx @@ -6,10 +6,7 @@ import { Link } from "react-router"; import { formatMoney } from "@/shared/lib"; -import type { - AccountPagePositionGroup, - AccountPagePositionSection, -} from "../AccountPage.vm"; +import type { AccountPagePositionGroup, AccountPagePositionSection } from "../../../model/positions"; import classes from "../AccountPage.module.css"; import { PositionLogo } from "./PositionLogo.view"; diff --git a/src/pages/account/ui/AccountPage/components/PositionLogo.view.tsx b/src/pages/account/ui/AccountPage/components/PositionLogo.view.tsx index 254d61b..4aeb6a7 100644 --- a/src/pages/account/ui/AccountPage/components/PositionLogo.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionLogo.view.tsx @@ -1,10 +1,9 @@ import type { FC } from "react"; import { Anchor, Avatar } from "@mantine/core"; +import { useState } from "react"; import { Link } from "react-router"; -import { useImageFallback } from "@/shared/lib"; - type PositionLogoProps = { label: string; src: null | string; @@ -13,7 +12,9 @@ type PositionLogoProps = { const PositionLogo: FC = (props) => { const { label, src } = props; - const { failed, onError } = useImageFallback(); + const [failed, setFailed] = useState(false); + // eslint-disable-next-line frontend/readability -- Rendering fallback belongs to this keyed logo, without page data or actions. + const onError = () => setFailed(true); const logo = src && !failed diff --git a/src/pages/account/ui/AccountPage/components/PositionSection.view.tsx b/src/pages/account/ui/AccountPage/components/PositionSection.view.tsx index af13558..8f4d19e 100644 --- a/src/pages/account/ui/AccountPage/components/PositionSection.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionSection.view.tsx @@ -2,7 +2,7 @@ import type { FC } from "react"; import { SimpleGrid } from "@mantine/core"; -import type { AccountPagePositionSection } from "../AccountPage.vm"; +import type { AccountPagePositionSection } from "../../../model/positions"; import { PositionCard } from "./PositionCard/PositionCard.view"; import { PositionGroupCard } from "./PositionGroupCard.view"; diff --git a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx index ff81b82..7e6acf1 100644 --- a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx +++ b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx @@ -6,9 +6,9 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { LedgerEntryRow } from "@/entities/broker-operation"; -import { formatSnapshotTime } from "@/shared/lib"; +import { SourceSyncStatus } from "@/shared/ui"; -import type { AccountPageRecentOperations } from "../AccountPage.vm"; +import type { AccountPageRecentOperations } from "../../../model/previews"; type RecentOperationsProps = { operations: AccountPageRecentOperations; @@ -18,22 +18,19 @@ const RecentOperations: FC = (props) => { const { t } = useTranslation(); const { operations } = props; + const errorText + = operations.state === "loaded" && operations.refreshError + ? t("accounts.refreshError") + : null; + return ( {operations.state === "loaded" && ( - <> - {!!operations.lastSuccessfulAt && ( - - {t("investment.sourceTime", { - date: formatSnapshotTime(operations.lastSuccessfulAt), - })} - - )} - - {!!operations.stale && {t("investment.stale")}} - - {!!operations.refreshError && {t("accounts.refreshError")}} - + )} {operations.state === "loading" && ( diff --git a/src/pages/account/ui/AccountPage/position-groups.test.tsx b/src/pages/account/ui/AccountPage/position-groups.test.tsx index 0322b47..278bb1e 100644 --- a/src/pages/account/ui/AccountPage/position-groups.test.tsx +++ b/src/pages/account/ui/AccountPage/position-groups.test.tsx @@ -1,52 +1,20 @@ import { MantineProvider } from "@mantine/core"; import { render, screen, within } from "@testing-library/react"; import { MemoryRouter } from "react-router"; -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; -import type { Portfolio } from "@/entities/portfolio"; - -import { toAccountPagePositionSections } from "./AccountPage.vm"; +import { toAccountPagePositionSections } from "../../model/positions"; +import { portfolio, position, second } from "../../model/positions.fixtures"; import { PositionSection } from "./components/PositionSection.view"; -const position: Portfolio["positions"][number] - = { - absoluteReturn: { currency: "RUB", value: "25" }, - account: { id: "a", name: "Основной" }, - averagePurchasePrice: { currency: "RUB", value: "280" }, - currentValue: { currency: "RUB", value: "375" }, - displayName: "Сбербанк", - figi: "FIGI-SBER", - isin: "RU0009029540", - logoName: null, - quantity: "1.25", - ticker: "SBER", - type: "STOCK", - }; - -const second - = { - ...position, - absoluteReturn: null, - account: { id: "b", name: "ИИС" }, - averagePurchasePrice: null, - currentValue: null, - quantity: "-2", - }; - -function sections(positions: Portfolio["positions"]) { - return toAccountPagePositionSections({ - cashBalances: [], - isStale: false, - lastSuccessfulAt: "2026-09-09T12:00:00Z", - positions, - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [], - }); -} - describe("Positions grouped by security", () => { it("shows one security with exact per-account quantities and valuations", () => { - const section = sections([position, second])[0]; + const section = toAccountPagePositionSections({ + ...portfolio, positions: [position, second], + })[0]; + + assert.isDefined(section); + render( @@ -61,6 +29,8 @@ describe("Positions grouped by security", () => { ).getAllByRole("listitem"); expect(rows).toHaveLength(2); + assert.isDefined(rows[0]); + assert.isDefined(rows[1]); expect( within(rows[0]).getByRole("link", { name: "Основной" }), ).toHaveAttribute("href", "/accounts/a"); @@ -76,75 +46,4 @@ describe("Positions grouped by security", () => { expect(within(rows[1]).getByText("Количество: -2")).toBeInTheDocument(); expect(rows[1]).not.toHaveTextContent("₽"); }); - - it("uses ISIN only when FIGI is absent, never merging by name or ticker", () => { - expect( - - sections([ - { - ...position, - figi: null, - }, - { ...second, figi: null }, - ])[0].groups, - ).toHaveLength(1); - expect( - sections([position, { ...second, figi: "OTHER-FIGI" }])[0].groups, - ).toHaveLength(2); - expect( - - sections([ - { - ...position, - figi: null, - isin: null, - }, - { - ...second, - figi: null, - isin: null, - }, - ])[0].groups, - ).toHaveLength(2); - }); - - it("preserves one-account cards and does not merge repeated rows on the same account", () => { - expect( - sections([position, { ...second, account: position.account }])[0].groups, - ).toHaveLength(2); - expect( - sections([ - { ...position, account: undefined }, - { ...second, account: undefined }, - ])[0].groups, - ).toHaveLength(2); - }); - - it("keeps currencies and purchase prices attached to their source positions", () => { - const source = [ - position, - { - ...second, - averagePurchasePrice: { currency: "USD", value: "0.99" }, - currentValue: { currency: "USD", value: "1.01" }, - }, - ]; - - const before = structuredClone(source); - const [group] = sections(source)[0].groups; - expect( - group.positions.map(item => [ - item.quantity, - item.currentValue, - item.averagePurchasePrice, - ]), - ).toEqual( - source.map(item => [ - item.quantity, - item.currentValue, - item.averagePurchasePrice, - ]), - ); - expect(source).toEqual(before); - }); }); diff --git a/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx b/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx index cbbe612..62ff297 100644 --- a/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx +++ b/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx @@ -6,12 +6,9 @@ import { MantineProvider } from "@mantine/core"; // eslint-disable-next-line testing-library/no-manual-cleanup -- Vitest globals are disabled, so RTL cannot register its automatic afterEach cleanup. import { cleanup, render, screen } from "@testing-library/react"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; -import { afterEach, expect, it } from "vitest"; +import { afterEach, assert, expect, it } from "vitest"; -import type { - AccountPagePosition, - AccountPagePositionSection, -} from "./AccountPage.vm"; +import type { AccountPagePosition, AccountPagePositionSection } from "../../model/positions"; import { PositionSection } from "./components/PositionSection.view"; @@ -79,6 +76,7 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor expect(current.height, context).toBeGreaterThan(0); if (index === 0) continue; const previous = bounds[index - 1]; + assert.isDefined(previous); if (kind === "other" && width === 1280) { // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx index 2ce0931..6e69741 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx @@ -203,6 +203,37 @@ describe("AccountsPage", () => { }); }); +it.each(["idle", "failed"] as const)( + "keeps partial empty membership distinct from no accounts (sync=%s)", + async (state) => { + server.use( + http.get("*/api/v1/brokerage-accounts", () => + HttpResponse.json(resource([], { + availability: "partial", + sync: { errorCode: null, id: null, state }, + })), + ), + ); + const client = createTestQueryClient(); + renderPage(client); + await waitFor(() => + expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({ + data: [], + meta: { availability: "partial" }, + }), + ); + expect(screen.queryByText("Нет инвестиционных счетов")) + .not.toBeInTheDocument(); + if (state === "failed") + // eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract. + expect(await screen.findByRole("button", { name: "Повторить" })) + .toBeVisible(); + else + // eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract. + expect(screen.getByRole("status")).toBeVisible(); + }, +); + it("keeps unknown membership loading instead of declaring no accounts", async () => { server.use( http.get("*/api/v1/brokerage-accounts", () => diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts index 5013448..e7cd89e 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts @@ -15,31 +15,31 @@ function useAccountsPageViewModel() { ) { if (!accounts.isError && accounts.meta?.sync.state !== "failed") return { state: "loading" as const }; - if (accounts.isError || accounts.meta?.sync.state === "failed") - return { - code: apiErrorCode(accounts.error), - retry: sync.retry, - state: "error" as const, - }; + + return { + code: apiErrorCode(accounts.error), + retry: sync.retry, + state: "error" as const, + }; } - if (!accounts.data?.length) - return { - lastSuccessfulAt: accounts.meta?.lastSuccessfulAt, - refreshError: accounts.isError || sync.transportError || sync.sourceError, - refreshing: accounts.isFetching || sync.refreshing, - retry: sync.retry, - sourceError: sync.sourceError, - state: "empty" as const, - }; - - return { - accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies), + const refresh = { lastSuccessfulAt: accounts.meta?.lastSuccessfulAt, refreshError: accounts.isError || sync.transportError || sync.sourceError, refreshing: accounts.isFetching || sync.refreshing, retry: sync.retry, sourceError: sync.sourceError, + }; + + if (accounts.data.length === 0) + return { + ...refresh, + state: "empty" as const, + }; + + return { + accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies), + ...refresh, state: "loaded" as const, }; } diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx index 3e007ab..a7908cf 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx @@ -5,16 +5,16 @@ import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { - AccountsRefreshState, - type AccountsRefreshStateProps, -} from "./AccountsRefreshState.view"; + InvestmentRefreshState, + type InvestmentRefreshStateProps, +} from "@/features/tbank-connection"; -const AccountsPageEmpty: FC = (props) => { +const AccountsPageEmpty: FC = (props) => { const { t } = useTranslation(); return ( - + = (props) => { - + - - ); + if (vm.bondState.loading || !vm.bond) return ( + + ); return ; }; diff --git a/src/pages/bond/ui/BondPage/BondPage.view.tsx b/src/pages/bond/ui/BondPage/BondPage.view.tsx index df3a659..e6debda 100644 --- a/src/pages/bond/ui/BondPage/BondPage.view.tsx +++ b/src/pages/bond/ui/BondPage/BondPage.view.tsx @@ -1,18 +1,12 @@ import type { FC } from "react"; import { - Accordion, Alert, - Anchor, - Avatar, Badge, - Button, Group, - Paper, Skeleton, Stack, Text, - Title, } from "@mantine/core"; import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; @@ -20,19 +14,25 @@ import { useTranslation } from "react-i18next"; import { InstrumentChart, InstrumentEvents, + InstrumentHeader, + InstrumentMetric, + InstrumentMetrics, + InstrumentPage, InstrumentPosition, - InstrumentSection, + InstrumentPrice, + InstrumentQuoteStatus, + InstrumentTerms, } from "@/features/instrument-details"; import { formatDate, formatDecimal, formatMoney, - formatSnapshotTime, } from "@/shared/lib"; import type { BondPageVm } from "./BondPage.vm"; -import classes from "./BondPage.module.css"; +import { BondCoupons } from "./components/BondCoupons.view"; +import { BondMetrics } from "./components/BondMetrics.view"; type BondPageViewProps = { vm: BondPageVm }; @@ -42,6 +42,8 @@ const BondPageView: FC = (props) => { const bond = vm.bond; if (!bond) return; + const subtitle = `${bond.ticker ?? ""} · ${bond.isin ?? ""}`; + const maturity = bond.maturityDate ? formatDate(bond.maturityDate) : t("bond.unknown"); @@ -75,11 +77,11 @@ const BondPageView: FC = (props) => { {t("bond.percentOfNominal")} - + {vm.quote?.pricePercent == undefined ? t("bond.unknown") : `${formatDecimal(vm.quote.pricePercent)} %`} - + {vm.quote?.changePercentagePoints != undefined && ( @@ -97,17 +99,17 @@ const BondPageView: FC = (props) => { - - - @@ -118,19 +120,7 @@ const BondPageView: FC = (props) => { - - {t(`stock.trading.${vm.quote?.tradingStatus}`, { - defaultValue: t("stock.trading.unknown"), - })} - - - {!!vm.quote?.priceAt && ( - - {t("stock.priceAt", { - date: formatSnapshotTime(vm.quote.priceAt), - })} - - )} + {!!vm.quote?.unavailable.length && ( @@ -143,33 +133,14 @@ const BondPageView: FC = (props) => { } return ( - - - -
    - - {bond.name[0]} - - -
    - - {bond.name} - - - - {bond.ticker} · {bond.isin} - -
    -
    - - -
    + + {!!bond.floatingCoupon && {t("bond.floatingCoupon")}} @@ -199,34 +170,31 @@ const BondPageView: FC = (props) => { )} -
    - - {t("bond.terms")} - + -
    - + - - + - + - + - -
    -
    -
    + + + + - + @@ -236,7 +204,12 @@ const BondPageView: FC = (props) => { vm={vm} /> - + = (props) => { incomeLabel={t("bond.coupons")} vm={vm} /> -
    - ); -}; - -const BondMetrics: FC = (props) => { - const { vm } = props; - const { t } = useTranslation(); - const creditRating = vm.metrics?.creditRating; - - let rating = t( - vm.metrics?.unavailable.includes("rating") - ? "bond.ratingUnavailable" - : "bond.ratingMissing", - ); - - if (creditRating) { - rating - = creditRating.status === "withdrawn" - ? t("bond.ratingWithdrawn") - : (creditRating.value ?? t("bond.unknown")); - } - - const couponDate = vm.metrics?.couponDate - ? formatDate(vm.metrics?.couponDate) - : t("bond.unknown"); - - const effectiveYield = vm.metrics?.effectiveYield - ? `${formatDecimal(vm.metrics?.effectiveYield.value)} %` - : t("bond.unknown"); - - const couponYield - = vm.metrics?.couponYieldPercent == undefined - ? t("bond.unknown") - : `${formatDecimal(vm.metrics?.couponYieldPercent)} %`; - - let yieldContent; - - if (vm.metrics?.effectiveYield) { - yieldContent = ( - - {t("bond.yieldSource", { - at: formatSnapshotTime(vm.metrics?.effectiveYield.tradeAt), - date: formatDate(vm.metrics?.effectiveYield.date), - price: formatDecimal( - vm.metrics?.effectiveYield.pricePercent, - ), - target: t( - `bond.yieldTargets.${vm.metrics?.effectiveYield.dateType}`, - { defaultValue: t("bond.yieldTargets.unknown") }, - ), - })} - - ); - } - else { - yieldContent = ( - - {t("bond.yieldUnavailable")} - - ); - } - - return ( - - {!!vm.metrics && ( - -
    - - - - - - - - - - - -
    - - {!!vm.metrics.creditRating && ( - - - {t("bond.ratingSource", { - date: formatDate(vm.metrics.creditRating.date), - })} - - - - {t("bond.ratingChecked", { - date: formatSnapshotTime(vm.metrics.creditRating.observedAt), - })} - - - )} - - - {t("bond.ratingCoverage")} - - - {yieldContent} - - - {t("bond.couponYieldNote")} - - - {!!vm.metrics?.couponYieldPriceAt && ( - - {t("bond.couponYieldPriceAt", { - date: formatSnapshotTime(vm.metrics?.couponYieldPriceAt), - })} - - )} - - {vm.metrics.unavailable.length > 0 && ( - - {t("bond.metricsPartial")} - - )} -
    - )} -
    - ); -}; - -const BondCoupons: FC = (props) => { - const { vm } = props; - const { t } = useTranslation(); - - return ( - - {!!vm.coupons && ( - - {!!(vm.coupons.estimateUnavailable || vm.coupons.positionIsStale) && ( - - {t("stock.estimateUnavailable")} - - )} - - {vm.upcoming.length > 0 - ? ( - vm.upcoming.map(coupon => ( - - )) - ) - : {t("bond.noCoupons")}} - - - {t("bond.couponWindow", { - from: formatDate(vm.coupons.from), - to: formatDate(vm.coupons.to), - })} - - - - {t("bond.couponNote")} - - - {!!vm.coupons.positionAt && ( - - {t("stock.snapshotAt", { - date: formatSnapshotTime(vm.coupons.positionAt), - })} - - )} - - - - {t("bond.couponHistory")} - - - - {vm.pastCoupons.length > 0 - ? ( - vm.pastCoupons.map(coupon => ( - - )) - ) - : {t("bond.noCoupons")}} - - - - - - )} - - ); -}; - -type MetricProps = { label: string; value: string }; - -const Metric: FC = props => ( -
    - - {props.label} - - - - {props.value} - -
    -); - -type CouponProps = { - coupon: NonNullable["coupons"][number]; -}; - -const Coupon: FC = (props) => { - const { coupon } = props; - const { t } = useTranslation(); - - return ( - - -
    - - {coupon.paymentDate - ? formatDate(coupon.paymentDate) - : t("stock.dateUnknown")} - - - - {t("bond.couponNumber", { - number: coupon.number ?? t("bond.unknown"), - })} - -
    - - - - {!!coupon.estimatedAmount && ( - - )} -
    - - - - {t(`bond.couponTypes.${coupon.couponType}`, { - defaultValue: t("bond.unknown"), - })} - - - {coupon.periodDays != undefined && ( - - {t("bond.couponDays", { count: coupon.periodDays })} - - )} - - {!!coupon.recordDate && ( - - {t("stock.record")}: {formatDate(coupon.recordDate)} - - )} - -
    + ); }; diff --git a/src/pages/bond/ui/BondPage/BondPage.vm.ts b/src/pages/bond/ui/BondPage/BondPage.vm.ts index a860caa..6a0af5f 100644 --- a/src/pages/bond/ui/BondPage/BondPage.vm.ts +++ b/src/pages/bond/ui/BondPage/BondPage.vm.ts @@ -1,9 +1,6 @@ -import { useParams, useSearchParams } from "react-router"; - -import type { OperationCategory } from "@/entities/broker-operation"; +import { useParams } from "react-router"; import { - type BondPeriod, useBond, useBondCandles, useBondCoupons, @@ -11,25 +8,15 @@ import { useBondPosition, useBondQuote, } from "@/entities/bond"; +import { useBrokerageOperationHistory } from "@/entities/broker-operation"; import { section, - useInstrumentOperations, + toInstrumentEventRows, + useInstrumentParameters, } from "@/features/instrument-details"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; -import { formatDate, instrumentLogoUrl } from "@/shared/lib"; - -const periods: BondPeriod[] = ["month", "halfYear", "year", "all"]; -const filters = ["all", "trades", "coupons", "other"] as const; - -type BondEventFilter = (typeof filters)[number]; - -const categories: Record = { - all: [], - coupons: ["income"], - other: ["tax", "fee", "transfer", "other"], - trades: ["trade"], -}; +import { compareDateDesc, instrumentLogoUrl } from "@/shared/lib"; type BondPageVm = ReturnType; @@ -37,13 +24,9 @@ function useBondPageVm() { const { bondId = "" } = useParams(); const bond = useBond(bondId); const uid = bond.data?.instrumentUid ?? ""; - const [parameters, setParameters] = useSearchParams(); - const period - = periods.find(value => value === parameters.get("period")) ?? "month"; - - const filter - = filters.find(value => value === parameters.get("events")) ?? "all"; + const { categories, filter, period, search, setFilter, setPeriod } + = useInstrumentParameters("coupons"); const quote = useBondQuote(uid); const metrics = useBondMetrics(uid); @@ -51,12 +34,11 @@ function useBondPageVm() { const position = useBondPosition(uid); const coupons = useBondCoupons(uid); - const history = useInstrumentOperations( - uid, - filter, - // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - categories[filter], - ); + const history = useBrokerageOperationHistory(uid ? "all" : undefined, { + categories, + instrumentUid: uid, + limit: 20, + }); const { operations } = history; @@ -64,9 +46,7 @@ function useBondPageVm() { { accountId: "all", candlePeriod: period, - categories: - // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - categories[filter], + categories, instrumentUid: uid, limit: 20, screen: "bond", @@ -74,24 +54,7 @@ function useBondPageVm() { [bond, quote, candles, position, metrics, coupons, operations], ); - const setParameter = (key: string, value: string) => { - const next = new URLSearchParams(parameters); - next.set(key, value); - setParameters(next, { replace: true }); - }; - - const chartData - = candles.data?.candles.map(candle => ({ - ...candle, - date: formatDate(candle.time), - price: Number(candle.close), - })) ?? []; - - const search = parameters.size > 0 ? `?${parameters.toString()}` : ""; - return { - averageLine: - null, bond: bond.data, bondState: section(bond, uid ? sync.retry : () => bond.refetch()), candles: candles.data, @@ -100,27 +63,11 @@ function useBondPageVm() { bond.data?.slug && bond.data.slug !== bondId ? `/bonds/${encodeURIComponent(bond.data.slug)}${search}` : null, - chartData, code: apiErrorCode(bond.error), coupons: coupons.data, couponsState: section(coupons, sync.retry), - eventRows: - operations.data?.items.map(item => ({ - ...item, - logoName: - item.logoName - ?? bond.data?.logoName - - ?? null, - ticker: - item.ticker - ?? bond.data?.ticker - - ?? null, - })) ?? [], + eventRows: toInstrumentEventRows(operations.data?.items, bond.data), filter, - hasIncompleteCandle: - candles.data?.candles.some(candle => !candle.isComplete) ?? false, hasPrevious: history.hasPrevious, logoSrc: instrumentLogoUrl(bond.data?.logoName), metrics: metrics.data, @@ -136,28 +83,23 @@ function useBondPageVm() { coupons.data?.coupons .filter(event => event.timing !== "upcoming") .toSorted((a, b) => - (b.paymentDate ?? b.recordDate ?? "").localeCompare( - a.paymentDate ?? a.recordDate ?? "", - ), + compareDateDesc(a.paymentDate, b.paymentDate), ) ?? [], period, position: position.data, positionState: section(position, sync.retry), - priceRows: chartData.toReversed(), quote: quote.data, quoteState: section(quote, sync.retry), refresh: sync.refresh, refreshError: sync.transportError || sync.sourceError, refreshing: sync.refreshing, - setFilter: (value: string) => setParameter("events", value), - setPeriod: (value: string) => setParameter("period", value), + setFilter, + setPeriod, upcoming: coupons.data?.coupons .filter(event => event.timing === "upcoming") .toSorted((a, b) => - (a.paymentDate ?? a.recordDate ?? "").localeCompare( - b.paymentDate ?? b.recordDate ?? "", - ), + compareDateDesc(b.paymentDate, a.paymentDate), ) ?? [], }; } diff --git a/src/pages/bond/ui/BondPage/components/BondCoupons.view.tsx b/src/pages/bond/ui/BondPage/components/BondCoupons.view.tsx new file mode 100644 index 0000000..930d0dd --- /dev/null +++ b/src/pages/bond/ui/BondPage/components/BondCoupons.view.tsx @@ -0,0 +1,150 @@ +import type { FC } from "react"; + +import { Accordion, Alert, Badge, Group, Paper, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +import type { BondCoupons as Coupons } from "@/entities/bond"; + +import { InstrumentMetric, InstrumentSection, type SectionState } from "@/features/instrument-details"; +import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; + +type BondCouponsProps = { + coupons?: Coupons; + pastCoupons: Coupons["coupons"]; + state: SectionState; + upcoming: Coupons["coupons"]; +}; + +const BondCoupons: FC = (props) => { + const { coupons, pastCoupons, state, upcoming } = props; + + const { t } = useTranslation(); + + return ( + + {!!coupons && ( + + {!!(coupons.estimateUnavailable || coupons.positionIsStale) && ( + + {t("stock.estimateUnavailable")} + + )} + + {upcoming.length > 0 + ? ( + upcoming.map(coupon => ( + + )) + ) + : {t("bond.noCoupons")}} + + + {t("bond.couponWindow", { + from: formatDate(coupons.from), + to: formatDate(coupons.to), + })} + + + + {t("bond.couponNote")} + + + {!!coupons.positionAt && ( + + {t("stock.snapshotAt", { + date: formatSnapshotTime(coupons.positionAt), + })} + + )} + + + + {t("bond.couponHistory")} + + + + {pastCoupons.length > 0 + ? ( + pastCoupons.map(coupon => ( + + )) + ) + : {t("bond.noCoupons")}} + + + + + + )} + + ); +}; + +type CouponProps = { + coupon: Coupons["coupons"][number]; +}; + +const Coupon: FC = (props) => { + const { coupon } = props; + const { t } = useTranslation(); + + return ( + + +
    + + {coupon.paymentDate + ? formatDate(coupon.paymentDate) + : t("stock.dateUnknown")} + + + + {t("bond.couponNumber", { + number: coupon.number ?? t("bond.unknown"), + })} + +
    + + + + {!!coupon.estimatedAmount && ( + + )} +
    + + + + {t(`bond.couponTypes.${coupon.couponType}`, { + defaultValue: t("bond.unknown"), + })} + + + {coupon.periodDays != undefined && ( + + {t("bond.couponDays", { count: coupon.periodDays })} + + )} + + {!!coupon.recordDate && ( + + {t("stock.record")}: {formatDate(coupon.recordDate)} + + )} + +
    + ); +}; + +export { BondCoupons }; diff --git a/src/pages/bond/ui/BondPage/components/BondMetrics.view.tsx b/src/pages/bond/ui/BondPage/components/BondMetrics.view.tsx new file mode 100644 index 0000000..bb64e45 --- /dev/null +++ b/src/pages/bond/ui/BondPage/components/BondMetrics.view.tsx @@ -0,0 +1,157 @@ +import type { FC } from "react"; + +import { Anchor, Group, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +import type { BondMetrics as Metrics } from "@/entities/bond"; + +import { + InstrumentMetric, + InstrumentMetrics, + InstrumentSection, + type SectionState, +} from "@/features/instrument-details"; +import { + formatDate, + formatDecimal, + formatMoney, + formatSnapshotTime, +} from "@/shared/lib"; + +type BondMetricsProps = { metrics?: Metrics; state: SectionState }; + +const BondMetrics: FC = (props) => { + const { metrics, state } = props; + const { t } = useTranslation(); + const creditRating = metrics?.creditRating; + + let rating = t( + metrics?.unavailable.includes("rating") + ? "bond.ratingUnavailable" + : "bond.ratingMissing", + ); + + if (creditRating) { + rating + = creditRating.status === "withdrawn" + ? t("bond.ratingWithdrawn") + : (creditRating.value ?? t("bond.unknown")); + } + + const couponDate = metrics?.couponDate + ? formatDate(metrics.couponDate) + : t("bond.unknown"); + + const effectiveYield = metrics?.effectiveYield + ? `${formatDecimal(metrics.effectiveYield.value)} %` + : t("bond.unknown"); + + const couponYield + = metrics?.couponYieldPercent == undefined + ? t("bond.unknown") + : `${formatDecimal(metrics.couponYieldPercent)} %`; + + let yieldContent; + + if (metrics?.effectiveYield) { + yieldContent = ( + + {t("bond.yieldSource", { + at: formatSnapshotTime(metrics.effectiveYield.tradeAt), + date: formatDate(metrics.effectiveYield.date), + price: formatDecimal( + metrics.effectiveYield.pricePercent, + ), + target: t( + `bond.yieldTargets.${metrics.effectiveYield.dateType}`, + { defaultValue: t("bond.yieldTargets.unknown") }, + ), + })} + + ); + } + else { + yieldContent = ( + + {t("bond.yieldUnavailable")} + + ); + } + + return ( + + {!!metrics && ( + + + + + + + + + + + + + + + + {!!metrics.creditRating && ( + + + {t("bond.ratingSource", { + date: formatDate(metrics.creditRating.date), + })} + + + + {t("bond.ratingChecked", { + date: formatSnapshotTime(metrics.creditRating.observedAt), + })} + + + )} + + + {t("bond.ratingCoverage")} + + + {yieldContent} + + + {t("bond.couponYieldNote")} + + + {!!metrics.couponYieldPriceAt && ( + + {t("bond.couponYieldPriceAt", { + date: formatSnapshotTime(metrics.couponYieldPriceAt), + })} + + )} + + {metrics.unavailable.length > 0 && ( + + {t("bond.metricsPartial")} + + )} + + )} + + ); +}; + +export { BondMetrics }; diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx b/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx index 98767c2..3cd4d51 100644 --- a/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx +++ b/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx @@ -1,9 +1,9 @@ import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { MemoryRouter } from "react-router"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; @@ -12,6 +12,8 @@ import { server } from "@/test/server"; import { BondsPage } from "./BondsPage"; +afterEach(() => vi.useRealTimers()); + describe("bond search", () => { it("waits for a meaningful query and uses the server-verified ticker link", async () => { const query = vi.fn(); @@ -46,6 +48,7 @@ describe("bond search", () => { clear: vi.fn(), refresh: () => Promise.resolve(false), }); + vi.useFakeTimers(); render( @@ -55,10 +58,16 @@ describe("bond search", () => { , ); + const input = screen.getByLabelText("Поиск облигаций"); + fireEvent.change(input, { target: { value: "S" } }); + await act(() => vi.advanceTimersByTimeAsync(300)); expect(query).not.toHaveBeenCalled(); - fireEvent.change(screen.getByLabelText("Поиск облигаций"), { - target: { value: "SU26238RMFS4" }, - }); + fireEvent.change(input, { target: { value: "SB" } }); + await act(() => vi.advanceTimersByTimeAsync(299)); + expect(query).not.toHaveBeenCalled(); + // The final input uses real timers for network and query notifications. + vi.useRealTimers(); + fireEvent.change(input, { target: { value: "SU26238RMFS4" } }); expect(await screen.findByRole("link", { name: /ОФЗ/ })).toHaveAttribute( "href", "/bonds/SU26238RMFS4", @@ -67,6 +76,6 @@ describe("bond search", () => { expect(screen.getByText("Режим: TQOB")).toBeVisible(); expect(screen.getByText("ISIN: RU0009029540")).toBeVisible(); expect(screen.getByText("Мосбиржа")).toBeVisible(); - await waitFor(() => expect(query).toHaveBeenCalledWith("SU26238RMFS4")); + await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith("SU26238RMFS4")); }); }); diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx b/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx index 5378658..8d114bc 100644 --- a/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx +++ b/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx @@ -1,24 +1,12 @@ import type { FC } from "react"; -import { - Alert, - Anchor, - Avatar, - Badge, - Button, - Group, - Paper, - Skeleton, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { LoadingSkeleton } from "@wealthledger/ui"; +import { Badge, Group, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { Link } from "react-router"; -import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; +import { + InstrumentSearch, + InstrumentSearchCard, +} from "@/features/instrument-search"; import { formatDate, formatDecimal, @@ -28,202 +16,101 @@ import { import type { BondsPageVm } from "./BondsPage.vm"; -import classes from "./BondsPage.module.css"; - type BondsPageViewProps = { vm: BondsPageVm }; -const BondsPageViewResults: FC = (props) => { - const { t } = useTranslation(); - const { vm } = props; - - if (vm.loading) { - return ( - - - - - - - - - - ); - } - - if (!vm.ready) return; - - if (vm.items.length > 0) { - return ( - - - {t("bond.searchVariants")} - - - {vm.items.map(bond => ( - - - {bond.name[0]} - - -
    - - {bond.name} - - - - {bond.ticker} ·{" "} - {bond.perpetual - ? t("bond.perpetual") - : t("bond.matures", { - date: bond.maturityDate - ? formatDate(bond.maturityDate) - : t("bond.unknown"), - })} - - - - - {t(`stock.exchanges.${bond.realExchange}`, { - defaultValue: t("stock.exchangeUnknown"), - })} - - - {!!bond.classCode && ( - - {t("stock.classCode", { code: bond.classCode })} - - )} - - - {!!bond.isin && ( - - {t("stock.isin", { value: bond.isin })} - - )} - - - {t("bond.nominal")}: {formatMoney(bond.nominal)} ·{" "} - {t("bond.couponFrequency")}:{" "} - {bond.couponQuantityPerYear ?? t("bond.unknown")} - - - {!!bond.floatingCoupon && ( - - {t("bond.floatingCoupon")} - - )} - - {bond.apiTradeAvailable === false && ( - - {t("stock.apiUnavailable")} - - )} -
    - -
    - - {t("bond.percentOfNominal")} - - - - {bond.pricePercent === null - ? t("bond.unknown") - : `${formatDecimal(bond.pricePercent)} %`} - - - - {bond.pricePercent !== null && bond.priceAt - ? t("stock.priceAt", { - date: formatSnapshotTime(bond.priceAt), - }) - : t("stock.priceUnavailable")} - - - {bond.apiTradeAvailable === true && ( - - {t("stock.apiAvailable")} - - )} -
    -
    - ))} -
    - ); - } - - return ( - - {t(vm.partial ? "investment.partial" : "bond.noSearchResults")} - - ); -}; - const BondsPageView: FC = (props) => { const { vm } = props; const { t } = useTranslation(); return ( - -
    - {t("bond.searchTitle")} - - - {t("bond.searchSubtitle")} - -
    - - - - {!!vm.partial && {t("investment.partial")}} - - - - {!vm.canSearch && {t("stock.searchHint")}} - - {!!vm.error && ( - - - - {t( - isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) - || isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken) - ? "bond.connect" - : "stock.sectionError", - )} + + {vm.items.map((bond) => { + const quote = ( + <> + + {t("bond.percentOfNominal")} - - {t("accounts.openSettings")} - + + {bond.pricePercent === null + ? t("bond.unknown") + : `${formatDecimal(bond.pricePercent)} %`} + - - - - )} + + {bond.pricePercent !== null && bond.priceAt + ? t("stock.priceAt", { + date: formatSnapshotTime(bond.priceAt), + }) + : t("stock.priceUnavailable")} + - -
    + {bond.apiTradeAvailable === true && ( + + {t("stock.apiAvailable")} + + )} + + ); + + return ( + + + {bond.ticker} ·{" "} + {bond.perpetual + ? t("bond.perpetual") + : t("bond.matures", { + date: bond.maturityDate + ? formatDate(bond.maturityDate) + : t("bond.unknown"), + })} + + + + + {t(`stock.exchanges.${bond.realExchange}`, { + defaultValue: t("stock.exchangeUnknown"), + })} + + + {!!bond.classCode && ( + + {t("stock.classCode", { code: bond.classCode })} + + )} + + + {!!bond.isin && ( + + {t("stock.isin", { value: bond.isin })} + + )} + + + {t("bond.nominal")}: {formatMoney(bond.nominal)} ·{" "} + {t("bond.couponFrequency")}:{" "} + {bond.couponQuantityPerYear ?? t("bond.unknown")} + + + {!!bond.floatingCoupon && ( + + {t("bond.floatingCoupon")} + + )} + + {bond.apiTradeAvailable === false && ( + + {t("stock.apiUnavailable")} + + )} + + ); + })} + ); }; diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts index d02b48f..db55a65 100644 --- a/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts +++ b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts @@ -1,25 +1,16 @@ -import type { ChangeEvent } from "react"; - -import { useDebouncedValue } from "@mantine/hooks"; -import { useSearchParams } from "react-router"; - import { MIN_BOND_SEARCH_LENGTH, useBondSearch } from "@/entities/bond"; +import { useInstrumentSearch } from "@/features/instrument-search"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; import { instrumentLogoUrl } from "@/shared/lib"; -const SEARCH_DEBOUNCE_MS = 300; - type BondsPageVm = ReturnType; function useBondsPageVm() { - const [parameters, setParameters] = useSearchParams(); - const query = parameters.get("q") ?? ""; - const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS); + const search = useInstrumentSearch(MIN_BOND_SEARCH_LENGTH); + const { canSearch, debounced, isWaiting, query } = search; const result = useBondSearch(debounced); const sync = useScreenSync({ query: debounced, screen: "bonds" }, [result]); - const canSearch = query.trim().length >= MIN_BOND_SEARCH_LENGTH; - const isWaiting = query.trim() !== debounced; return { canSearch, @@ -29,7 +20,7 @@ function useBondsPageVm() { result.data?.map(bond => ({ ...bond, logoSrc: instrumentLogoUrl(bond.logoName), - to: `/bonds/${encodeURIComponent(bond.slug ?? bond.instrumentUid)}`, + to: `/bonds/${encodeURIComponent(bond.slug)}`, })) ?? [], loading: canSearch @@ -37,10 +28,7 @@ function useBondsPageVm() { || (!result.data && !result.isError && result.meta?.sync.state !== "failed")), - onQueryChange: (event: ChangeEvent) => { - const q = event.currentTarget.value; - setParameters(q ? { q } : {}, { replace: true }); - }, + onQueryChange: search.onQueryChange, partial: result.meta?.availability === "partial", query, ready: diff --git a/src/pages/card/ui/CardPage/CardHistory.view.tsx b/src/pages/card/ui/CardPage/CardHistory.view.tsx new file mode 100644 index 0000000..5111a95 --- /dev/null +++ b/src/pages/card/ui/CardPage/CardHistory.view.tsx @@ -0,0 +1,126 @@ +import { + ActionIcon, + Anchor, + Group, + Skeleton, + Stack, + Text, +} from "@mantine/core"; +import { + DeleteIcon, + EditIcon, + OperationIcon, +} from "@wealthledger/icons"; +import { EntryCard, LoadingSkeleton } from "@wealthledger/ui"; +import { type FC } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; + +import { + bankOperationAppearance, +} from "@/entities/bank-card"; + +import type { CardPageVm } from "./CardPage.vm"; + +import classes from "./CardPage.module.css"; + +type CardHistoryProps = Pick; + +// eslint-disable-next-line sonarjs/function-return-type -- React renders elements, lists and empty output. +const CardHistory: FC = (props) => { + const vm = props; + const { t } = useTranslation(); + + if (vm.rows) { + if (vm.rows.length > 0) { + return vm.rows.map((row) => { + const details = row.otherCard + ? ( + + {t("bank.card")} + + ) + : undefined; + + return ( +
    + + + + + + + + + + )} + color={row.color} + details={details} + icon={( + + )} + initial={row.title.charAt(0)} + metadata={row.metadata} + title={row.title} + tone={row.tone} + value={row.value} + valueLabel={t(`bank.kinds.${row.item.kind}`)} + wrap + /> +
    + ); + }); + } + + return {t("bank.noOperations")}; + } + + if (vm.history.loading) { + return ( + + + + + + + + ); + } + + return null; +}; + +export { CardHistory }; diff --git a/src/pages/card/ui/CardPage/CardPage.module.css b/src/pages/card/ui/CardPage/CardPage.module.css index c7171ed..b67e013 100644 --- a/src/pages/card/ui/CardPage/CardPage.module.css +++ b/src/pages/card/ui/CardPage/CardPage.module.css @@ -32,19 +32,6 @@ .operation { min-width: 0; } -.operation p { - white-space: normal; - overflow-wrap: anywhere; -} -.operation > div > div { - min-width: 0; -} -@media (min-width: 36em) { - .operation > div:first-child { - grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%); - } -} - .operations[data-pending] { opacity: 0.55; } diff --git a/src/pages/card/ui/CardPage/CardPage.test.tsx b/src/pages/card/ui/CardPage/CardPage.test.tsx index 540b536..8140403 100644 --- a/src/pages/card/ui/CardPage/CardPage.test.tsx +++ b/src/pages/card/ui/CardPage/CardPage.test.tsx @@ -349,6 +349,12 @@ describe("CardPage", () => { it("archives without deleting history and restores financial actions", async () => { setup(); await screen.findByText("Перевод на «Запасная»"); + server.use(http.patch("*/api/v1/bank-cards/a", () => + HttpResponse.json({ code: "BANK_WRITE_CONFLICT" }, { status: 409 }), + )); + fireEvent.click(screen.getByRole("button", { name: "Убрать в архив" })); + await screen.findByRole("alert"); + expect(screen.getByRole("button", { name: "Добавить операцию" })).toBeEnabled(); let current = card; const bodies: unknown[] = []; server.use( @@ -376,6 +382,7 @@ describe("CardPage", () => { ); fireEvent.click(screen.getByRole("button", { name: "Убрать в архив" })); await screen.findByRole("button", { name: "Вернуть из архива" }); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect( screen.getByRole("button", { name: "Добавить операцию" }), ).toBeDisabled(); diff --git a/src/pages/card/ui/CardPage/CardPage.view.tsx b/src/pages/card/ui/CardPage/CardPage.view.tsx index 24cd872..e839f64 100644 --- a/src/pages/card/ui/CardPage/CardPage.view.tsx +++ b/src/pages/card/ui/CardPage/CardPage.view.tsx @@ -1,6 +1,5 @@ import { ActionIcon, - Anchor, Button, Group, Pagination, @@ -14,10 +13,9 @@ import { AddIcon, DeleteIcon, EditIcon, - OperationIcon, RestoreIcon, } from "@wealthledger/icons"; -import { EntryCard, LoadingSkeleton, StateView } from "@wealthledger/ui"; +import { LoadingSkeleton, StateView } from "@wealthledger/ui"; import { type FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; @@ -25,111 +23,18 @@ import { Link } from "react-router"; import { BankCardFace, BankFeedback, - bankOperationAppearance, BankPeriodPicker, BankSummaryView, } from "@/entities/bank-card"; import type { CardPageVm } from "./CardPage.vm"; +import { CardHistory } from "./CardHistory.view"; import classes from "./CardPage.module.css"; import { useCardPagePresentationVm } from "./CardPagePresentation.vm"; type CardPageViewProps = { vm: CardPageVm }; -// eslint-disable-next-line sonarjs/function-return-type -- React renders elements, lists and empty output. -const CardPageViewHistory: FC = (props) => { - const { vm } = props; - const { t } = useTranslation(); - const card = vm.card; - - if (!card) return; - - if (vm.rows) { - if (vm.rows.length > 0) { - return vm.rows.map((row) => { - const details = row.otherCard - ? ( - - {t("bank.card")} - - ) - : undefined; - - return ( -
    - - - - - - - - - - )} - color={row.color} - details={details} - icon={( - - )} - initial={row.title[0]} - metadata={row.metadata} - title={row.title} - tone={row.tone} - value={row.value} - valueLabel={t(`bank.kinds.${row.item.kind}`)} - /> -
    - ); - }); - } - - return {t("bank.noOperations")}; - } - - if (vm.history.loading) { - return ( - - - - - - - - ); - } -}; - const CardPageView: FC = (props) => { const { vm } = props; @@ -288,7 +193,7 @@ const CardPageView: FC = (props) => { gap="lg" key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.page ?? "loading"}`} > - +
    {vm.history.error && ( diff --git a/src/pages/card/ui/CardPage/CardPage.vm.ts b/src/pages/card/ui/CardPage/CardPage.vm.ts index fa8993e..e8b067c 100644 --- a/src/pages/card/ui/CardPage/CardPage.vm.ts +++ b/src/pages/card/ui/CardPage/CardPage.vm.ts @@ -34,7 +34,6 @@ function useCardPageVm() { const card = useBankCard(cardId); const updateCard = useUpdateBankCard(); - const [archiveError, setArchiveError] = useState(); const cards = useBankCards(true); const history = useBankOperations(cardId, { @@ -94,7 +93,7 @@ function useCardPageVm() { }); return { - archiveError, + archiveError: updateCard.isError ? updateCard.error : undefined, archivePending: updateCard.isPending, canAddOperation: !card.data?.archivedAt && Boolean(cards.data), card: card.data, @@ -149,19 +148,13 @@ function useCardPageVm() { loading: summary.isLoading, retry: () => void summary.refetch(), }, - toggleArchive: async () => { + toggleArchive: () => { if (!card.data) return; - setArchiveError(undefined); - try { - await updateCard.mutateAsync({ - body: { archived: !card.data.archivedAt, version: card.data.version }, - id: card.data.id, - }); - } - catch (error) { - setArchiveError(error); - } + updateCard.mutate({ + body: { archived: !card.data.archivedAt, version: card.data.version }, + id: card.data.id, + }); }, totalPages: pageSize === "all" diff --git a/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts b/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts index 4fa4e68..14f57b7 100644 --- a/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts +++ b/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts @@ -1,12 +1,18 @@ +import type { Pagination } from "@mantine/core"; import type { ComponentProps } from "react"; -import { Pagination } from "@mantine/core"; import { useLayoutEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import type { CardPageVm } from "./CardPage.vm"; -function useCardPagePresentationVm(vm: CardPageVm) { +function useCardPagePresentationVm(vm: Pick) { const { t } = useTranslation(); const card = vm.card; const paginationRef = useRef(null); diff --git a/src/pages/card/ui/OperationForm/OperationFields.vm.ts b/src/pages/card/ui/OperationForm/OperationFields.vm.ts index 53cc5dd..bca394c 100644 --- a/src/pages/card/ui/OperationForm/OperationFields.vm.ts +++ b/src/pages/card/ui/OperationForm/OperationFields.vm.ts @@ -1,28 +1,16 @@ -import type { ComponentProps } from "react"; - -import { DateInput } from "@mantine/dates"; import { useController, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import type { OperationValues } from "./OperationForm.types"; +import type { + OperationFieldsProps, + OperationValues, +} from "./OperationForm.types"; -type Choice = { label: string; value: string }; - -type OperationFieldsProps = { - cards: Choice[]; - categories: Choice[]; - dateInputValue: (value: string) => string; - defaultCategory: string; - editing: boolean; - kind: OperationValues["kind"]; - kinds: Choice[]; - maxDate: () => string; - refundCardId: string; - targets: Choice[]; - validDate: (value: string) => boolean; -}; - -function useOperationFieldsVm(props: OperationFieldsProps) { +function useOperationFieldsVm(props: Pick) { const { t } = useTranslation(); const { @@ -56,38 +44,13 @@ function useOperationFieldsVm(props: OperationFieldsProps) { props.kind === "transfer" ? "bank.source" : "bank.operationCard", ); - const dateParser: ComponentProps["dateParser"] = value => - props.validDate(props.dateInputValue(value)) - ? props.dateInputValue(value) - : null; - - const onDateBlur: ComponentProps["onBlur"] = (event) => { - dateField.onChange(props.dateInputValue(event.currentTarget.value)); - dateField.onBlur(); - }; - - const onDateChange: (value: null | string) => void = value => - dateField.onChange(value ?? ""); - - const onDateInput: ComponentProps["onInput"] = event => - dateField.onChange(props.dateInputValue(event.currentTarget.value)); - - const dateValue = props.validDate(dateField.value) - ? dateField.value - : undefined; - return { cardIdField, categoryIdField, - dateField, - dateParser, - dateValue, + date: { ...dateField, ...props.bindDate(dateField) }, errors, isSubmitting, kindField, - onDateBlur, - onDateChange, - onDateInput, onKindChange, register, sourceCardLabel, diff --git a/src/pages/card/ui/OperationForm/OperationForm.module.css b/src/pages/card/ui/OperationForm/OperationForm.module.css deleted file mode 100644 index fef62f4..0000000 --- a/src/pages/card/ui/OperationForm/OperationForm.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.fieldset { - border: 0; - margin: 0; - padding: 0; - min-width: 0; -} diff --git a/src/pages/card/ui/OperationForm/OperationForm.schema.test.ts b/src/pages/card/ui/OperationForm/OperationForm.schema.test.ts new file mode 100644 index 0000000..7fead5f --- /dev/null +++ b/src/pages/card/ui/OperationForm/OperationForm.schema.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import type { BankCard } from "@/entities/bank-card"; + +import type { OperationValues } from "./OperationForm.types"; + +import { createOperationSchema } from "./OperationForm.schema"; + +const card: BankCard = { + archivedAt: null, + balance: { currency: "RUB", value: "0" }, + bankName: null, + id: "card", + lastFour: null, + name: "Карта", + openingBalance: { currency: "RUB", value: "0" }, + openingDate: "2026-01-01", + paymentSystem: null, + version: 1, +}; + +const values: OperationValues = { + amount: "1", + cardId: card.id, + categoryId: "", + date: "2026-01-02", + description: "", + kind: "expense", + originalExpenseId: "", + targetCardId: "", +}; + +describe("expense category validation", () => { + it.each([ + ["", [{ id: "default", isDefault: true }], true], + ["chosen", [{ id: "chosen", isDefault: false }], true], + ["", [{ id: "chosen", isDefault: false }], false], + ["unknown", [{ id: "default", isDefault: true }], false], + ["", undefined, false], + ] as const)("checks category %s with current categories %j", ( + categoryId, categories, valid, + ) => { + const schema = createOperationSchema({ + cards: [card], + categories: categories ? [...categories] : undefined, + }); + + const result = schema.safeParse({ ...values, categoryId }); + expect(result.success).toBe(valid); + if (!result.success) + // eslint-disable-next-line vitest/no-conditional-expect -- Invalid table cases retain the category field error boundary. + expect(result.error.issues.map(issue => issue.path)) + .toEqual([["categoryId"]]); + }); +}); diff --git a/src/pages/card/ui/OperationForm/OperationForm.schema.ts b/src/pages/card/ui/OperationForm/OperationForm.schema.ts index 7173642..b73b470 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.schema.ts +++ b/src/pages/card/ui/OperationForm/OperationForm.schema.ts @@ -10,8 +10,6 @@ import { } from "@/entities/bank-card"; import { validationMessage } from "@/shared/i18n"; -import type { OperationValues } from "./OperationForm.types"; - // ZodIssueCode is deprecated; this is the custom refinement code accepted by Zod. enum FormIssueCode { Custom = "custom", @@ -43,6 +41,8 @@ const operationSchema = z.object({ targetCardId: z.string(), }); +type OperationValues = z.infer; + function createOperationSchema({ cards, categories, @@ -83,16 +83,19 @@ function createOperationSchema({ path: ["date"], ...validationMessage("bank.validation.start")(undefined), }); - if (values.kind === "refund") + + if (values.kind === "refund") { validateRefund(values, context, expense, limit); - if ( - values.kind === "expense" - && !categories?.some( - c => - c.id - === (values.categoryId || categories?.find(c => c.isDefault)?.id), - ) - ) + + return; + } + + if (values.kind !== "expense") return; + + const effectiveCategoryId + = values.categoryId || categories?.find(c => c.isDefault)?.id; + + if (!categories?.some(c => c.id === effectiveCategoryId)) context.addIssue({ code: FormIssueCode.Custom, path: ["categoryId"], @@ -128,4 +131,4 @@ function validateRefund( }); } -export { createOperationSchema }; +export { createOperationSchema, type OperationValues }; diff --git a/src/pages/card/ui/OperationForm/OperationForm.stories.tsx b/src/pages/card/ui/OperationForm/OperationForm.stories.tsx index 78d57d9..32c9211 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.stories.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.stories.tsx @@ -43,9 +43,14 @@ const OperationExample: FC = (props) => { ) } fields={{ + bindDate: field => ({ + ...field, + dateParser: () => "2026-08-01", + onChange: value => field.onChange(value ?? ""), + onInput: () => {}, + }), cards: [{ label: "Учебная карта", value: "demo" }], categories: [], - dateInputValue: () => "2026-08-01", defaultCategory: "", editing: true, kind, @@ -53,7 +58,6 @@ const OperationExample: FC = (props) => { maxDate: () => "2026-08-27", refundCardId: "demo", targets: [], - validDate: value => value === "2026-08-01", }} latest={ props.conflict @@ -91,7 +95,6 @@ const OperationExample: FC = (props) => { }, ], limit: { currency: "RUB", value: "500" }, - onSelect: () => {}, }} saveDisabled={false} sourcePage={1} @@ -108,7 +111,7 @@ const meta = { docs: { description: { component: - "Synthetic presentation без API/VM. Date callbacks фиксируют fixture 01.08.2026; денежная validation, recovery и nested category flow проверяются application tests. Actions только локальные.", + "Synthetic presentation без API/VM. денежная validation, recovery и nested category flow проверяются application tests. Actions только локальные.", }, }, }, diff --git a/src/pages/card/ui/OperationForm/OperationForm.test.tsx b/src/pages/card/ui/OperationForm/OperationForm.test.tsx index 1729b71..7a005f9 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.test.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.test.tsx @@ -245,14 +245,19 @@ describe("OperationForm", () => { version: 1, }; + const recent = { + ...purchase, + availableRefund: money("100"), + description: "Недавняя покупка", + id: "recent", + }; + server.use( http.get("*/api/v1/bank-cards/a/expenses", ({ request }) => { return HttpResponse.json({ hasNext: new URL(request.url).searchParams.get("page") !== "2", items: - new URL(request.url).searchParams.get("page") === "2" - ? [purchase] - : [], + [new URL(request.url).searchParams.get("page") === "2" ? purchase : recent], page: 1, total: 1, }); @@ -260,9 +265,19 @@ describe("OperationForm", () => { http.get("*/api/v1/bank-cards/operations/purchase", () => HttpResponse.json(purchase), ), + http.get("*/api/v1/bank-cards/operations/recent", () => + HttpResponse.json(recent), + ), ); const close = setup(); await select(/Вид операции/, "Возврат"); + await select(/Исходная покупка/, /Недавняя покупка/); + await screen.findByText("Доступно к возврату: 100 ₽"); + fireEvent.change(screen.getByLabelText(/Сумма/), { + target: { value: "150" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await screen.findByText("Сумма превышает доступный возврат."); await waitFor(() => expect(screen.getByRole("button", { name: "Далее" })).toBeEnabled(), ); @@ -452,7 +467,7 @@ describe("OperationForm", () => { expect(screen.getByLabelText(/Сумма/)).toHaveValue("150"); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(bodies).toHaveLength(2)); - expect(bodies[1].version).toBe(1); + expect(bodies[1]?.version).toBe(1); fireEvent.click( screen.getByRole("button", { name: "Использовать актуальную версию 2" }), ); diff --git a/src/pages/card/ui/OperationForm/OperationForm.types.ts b/src/pages/card/ui/OperationForm/OperationForm.types.ts index 09ba82e..d060017 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.types.ts +++ b/src/pages/card/ui/OperationForm/OperationForm.types.ts @@ -1,4 +1,25 @@ -import type { BankCard, BankOperation } from "@/entities/bank-card"; +import type { + BankCard, + bankDateField, + BankOperation, +} from "@/entities/bank-card"; + +import type { OperationValues } from "./OperationForm.schema"; + +type Choice = { label: string; value: string }; + +type OperationFieldsProps = { + bindDate: typeof bankDateField; + cards: Choice[]; + categories: Choice[]; + defaultCategory: string; + editing: boolean; + kind: OperationValues["kind"]; + kinds: Choice[]; + maxDate: () => string; + refundCardId: string; + targets: Choice[]; +}; type OperationFormProps = { card: BankCard; @@ -7,15 +28,11 @@ type OperationFormProps = { operation?: BankOperation; }; -type OperationValues = { - amount: string; - cardId: string; - categoryId: string; - date: string; - description: string; - kind: BankOperation["kind"]; - originalExpenseId: string; - targetCardId: string; +export type { + Choice, + OperationFieldsProps, + OperationFormProps, + }; -export type { OperationFormProps, OperationValues }; +export { type OperationValues } from "./OperationForm.schema"; diff --git a/src/pages/card/ui/OperationForm/OperationForm.view.tsx b/src/pages/card/ui/OperationForm/OperationForm.view.tsx index 159b423..7161d1f 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.view.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.view.tsx @@ -2,6 +2,7 @@ import type { FC, ReactNode, SubmitEventHandler } from "react"; import { Button, + Fieldset, Group, Modal, Select, @@ -17,41 +18,19 @@ import type { BankOperation } from "@/entities/bank-card"; import { formatMoney } from "@/shared/lib"; -import type { OperationValues } from "./OperationForm.types"; +import type { Choice, OperationFieldsProps } from "./OperationForm.types"; import { useOperationFieldsVm } from "./OperationFields.vm"; -import classes from "./OperationForm.module.css"; import { useRefundFieldsVm } from "./RefundFields.vm"; -type Choice = { label: string; value: string }; - -type OperationFieldsProps = { - cards: Choice[]; - categories: Choice[]; - dateInputValue: (value: string) => string; - defaultCategory: string; - editing: boolean; - kind: OperationValues["kind"]; - kinds: Choice[]; - maxDate: () => string; - refundCardId: string; - targets: Choice[]; - validDate: (value: string) => boolean; -}; - const OperationFields: FC = (props) => { const { cardIdField, categoryIdField, - dateField, - dateParser, - dateValue, + date, errors, isSubmitting, kindField, - onDateBlur, - onDateChange, - onDateInput, onKindChange, register, sourceCardLabel, @@ -99,21 +78,16 @@ const OperationFields: FC = (props) => { /> @@ -142,12 +116,11 @@ type RefundFieldsProps = { editing: boolean; expenses: Choice[]; limit: BankOperation["availableRefund"] | undefined; - onSelect: (id: string) => void; }; const RefundFields: FC = (props) => { const { errors, isSubmitting, onExpenseChange, originalExpenseIdField, t } - = useRefundFieldsVm(props); + = useRefundFieldsVm(); return ( @@ -220,7 +193,7 @@ const OperationFormView: FC = (props) => { title={displayTitle} >
    -
    +
    @@ -300,7 +273,7 @@ const OperationFormView: FC = (props) => { -
    +
    ); diff --git a/src/pages/card/ui/OperationForm/OperationForm.vm.ts b/src/pages/card/ui/OperationForm/OperationForm.vm.ts index a8eb1da..e6d5ae1 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.vm.ts +++ b/src/pages/card/ui/OperationForm/OperationForm.vm.ts @@ -4,14 +4,12 @@ import { useForm, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { - bankDateInputValue, - bankDateSchema, + bankDateField, bankMoneyValue, type BankOperation, bankOperationKinds, bankToday, useBankCategories, - useBankExpenses, useBankFormFocus, useBankOperation, useCreateBankOperation, @@ -25,6 +23,7 @@ import type { } from "./OperationForm.types"; import { createOperationSchema } from "./OperationForm.schema"; +import { useRefundPurchase } from "./useRefundPurchase"; const useOperationFormVm = (props: OperationFormProps) => { useBankFormFocus(); @@ -33,13 +32,7 @@ const useOperationFormVm = (props: OperationFormProps) => { const [categoriesOpen, setCategoriesOpen] = useState(false); const [version, setVersion] = useState(props.operation?.version ?? 1); const [latest, setLatest] = useState(); - const [sourcePage, setSourcePage] = useState(1); - const [sourceId, setSourceId] = useState( - props.operation?.originalExpenseId ?? "", - ); - - const sourceQuery = useBankOperation(sourceId || undefined); const currentQuery = useBankOperation(props.operation?.id); const [refundLimit, setRefundLimit] = useState( @@ -50,6 +43,7 @@ const useOperationFormVm = (props: OperationFormProps) => { const [submittedValues, setSubmittedValues] = useState(); const [error, setError] = useState(); const categories = useBankCategories(); + const defaultCategoryId = categories.data?.find(c => c.isDefault)?.id; const operationCreation = useCreateBankOperation(); const updateOperation = useUpdateBankOperation(); @@ -64,7 +58,7 @@ const useOperationFormVm = (props: OperationFormProps) => { originalExpenseId: props.operation?.originalExpenseId ?? "", targetCardId: props.operation?.targetCardId ?? "", }, - resolver: zodResolver( + resolver: (values, context, options) => zodResolver( createOperationSchema({ cards: props.cards, categories: categories.data, @@ -74,16 +68,17 @@ const useOperationFormVm = (props: OperationFormProps) => { : sourceQuery.data?.availableRefund, submittedValues, }), - ), + )(values, context, options), }); const kind = useWatch({ control: methods.control, name: "kind" }); - const expenses = useBankExpenses( - kind === "refund" ? props.card.id : undefined, - { page: sourcePage }, + const purchase = useRefundPurchase( + methods.control, props.card.id, kind, Boolean(props.operation), ); + const { sourceId, sourceQuery } = purchase; + const source = useWatch({ control: methods.control, name: "cardId" }); const isPending = !opened || methods.formState.isSubmitting; @@ -104,7 +99,7 @@ const useOperationFormVm = (props: OperationFormProps) => { }), ...(values.kind === "expense" && { categoryId: - values.categoryId || categories.data?.find(c => c.isDefault)?.id, + values.categoryId || defaultCategoryId, }), ...(values.kind === "refund" && { originalExpenseId: values.originalExpenseId, @@ -147,23 +142,6 @@ const useOperationFormVm = (props: OperationFormProps) => { else setError(result.error); }; - const expenseChoice = (item: BankOperation) => ({ - label: t("bank.expenseChoice", { - amount: formatMoney(item.amount), - available: formatMoney(item.availableRefund), - date: formatDate(item.date), - name: item.description || item.categoryName, - }), - value: item.id, - }); - - const expenseOptions - = expenses.data?.items.map(element => expenseChoice(element)) ?? []; - - const selected = sourceQuery.data - ? expenseChoice(sourceQuery.data) - : undefined; - return { categoriesError: categories.isError ? categories.error @@ -171,16 +149,14 @@ const useOperationFormVm = (props: OperationFormProps) => { categoriesOpen, closeCategories: () => setCategoriesOpen(false), error, - expensesError: expenses.isError - ? expenses.error - : null, + expensesError: purchase.error, methods, onCategoryCreated: (id: string) => { methods.setValue("categoryId", id); setCategoriesOpen(false); }, retryCategories: () => void categories.refetch(), - retryExpenses: () => void expenses.refetch(), + retryExpenses: purchase.retry, retrySource: () => void sourceQuery.refetch(), sourceError: sourceQuery.isError ? sourceQuery.error @@ -188,15 +164,13 @@ const useOperationFormVm = (props: OperationFormProps) => { view: { categoriesOpen, editing: Boolean(props.operation), - emptyExpenses: Boolean( - expenses.data && expenses.data.items.length === 0 && !props.operation, - ), + emptyExpenses: purchase.empty, fields: { + bindDate: bankDateField, cards: options, categories: categories.data?.map(c => ({ label: c.name, value: c.id })) ?? [], - dateInputValue: bankDateInputValue, - defaultCategory: categories.data?.find(c => c.isDefault)?.id ?? "", + defaultCategory: defaultCategoryId ?? "", editing: Boolean(props.operation), kind, kinds: bankOperationKinds.map(value => ({ @@ -206,7 +180,6 @@ const useOperationFormVm = (props: OperationFormProps) => { maxDate: bankToday, refundCardId: props.card.id, targets: options.filter(c => c.value !== source), - validDate: (value: string) => bankDateSchema.safeParse(value).success, }, latest: latest ? { @@ -219,35 +192,30 @@ const useOperationFormVm = (props: OperationFormProps) => { version: latest.version, } : undefined, - loadingExpenses: expenses.isLoading, - nextDisabled: !expenses.data?.hasNext || expenses.isFetching, + loadingExpenses: purchase.loading, + nextDisabled: purchase.nextDisabled, onAddCategory: () => setCategoriesOpen(true), onCancel: () => setOpened(false), onClose: () => !isPending && !categoriesOpen && setOpened(false), onExitTransitionEnd: props.onClose, - onNext: () => setSourcePage(sourcePage + 1), - onPrevious: () => setSourcePage(sourcePage - 1), + onNext: purchase.onNext, + onPrevious: purchase.onPrevious, onReload: error && (sourceId || props.operation) ? onReload : undefined, onSubmit, opened, pending: isPending, - previousDisabled: sourcePage === 1 || expenses.isFetching, + previousDisabled: purchase.previousDisabled, refund: { editing: Boolean(props.operation), - expenses: - selected - && expenseOptions.every(item => item.value !== selected.value) - ? [selected, ...expenseOptions] - : expenseOptions, + expenses: purchase.options, limit: props.operation ? refundLimit : sourceQuery.data?.availableRefund, - onSelect: setSourceId, }, saveDisabled: (kind === "expense" && !categories.data) || (kind === "refund" && !sourceQuery.data), - sourcePage, + sourcePage: purchase.page, }, }; }; diff --git a/src/pages/card/ui/OperationForm/RefundFields.vm.ts b/src/pages/card/ui/OperationForm/RefundFields.vm.ts index c53fab9..afaf3ec 100644 --- a/src/pages/card/ui/OperationForm/RefundFields.vm.ts +++ b/src/pages/card/ui/OperationForm/RefundFields.vm.ts @@ -1,20 +1,9 @@ import { useController, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import type { BankOperation } from "@/entities/bank-card"; - import type { OperationValues } from "./OperationForm.types"; -type Choice = { label: string; value: string }; - -type RefundFieldsProps = { - editing: boolean; - expenses: Choice[]; - limit: BankOperation["availableRefund"] | undefined; - onSelect: (id: string) => void; -}; - -function useRefundFieldsVm(props: RefundFieldsProps) { +function useRefundFieldsVm() { const { t } = useTranslation(); const { @@ -29,7 +18,6 @@ function useRefundFieldsVm(props: RefundFieldsProps) { const onExpenseChange: (value: null | string) => void = (value) => { originalExpenseIdField.onChange(value ?? ""); - props.onSelect(value ?? ""); }; return { errors, isSubmitting, onExpenseChange, originalExpenseIdField, t }; diff --git a/src/pages/card/ui/OperationForm/useRefundPurchase.ts b/src/pages/card/ui/OperationForm/useRefundPurchase.ts new file mode 100644 index 0000000..0cd05d7 --- /dev/null +++ b/src/pages/card/ui/OperationForm/useRefundPurchase.ts @@ -0,0 +1,61 @@ +import type { Control } from "react-hook-form"; + +import { useState } from "react"; +import { useWatch } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import { + type BankOperation, + useBankExpenses, + useBankOperation, +} from "@/entities/bank-card"; +import { formatDate, formatMoney } from "@/shared/lib"; + +import type { OperationValues } from "./OperationForm.types"; + +function useRefundPurchase( + control: Control, + cardId: string, + kind: OperationValues["kind"], + isEditing: boolean, +) { + const { t } = useTranslation(); + const [page, setPage] = useState(1); + const sourceId = useWatch({ control, name: "originalExpenseId" }); + const sourceQuery = useBankOperation(sourceId || undefined); + const expenses = useBankExpenses(kind === "refund" ? cardId : undefined, { page }); + + const choice = (item: BankOperation) => ({ + label: t("bank.expenseChoice", { + amount: formatMoney(item.amount), + available: formatMoney(item.availableRefund), + date: formatDate(item.date), + name: item.description || item.categoryName, + }), + value: item.id, + }); + + const options = expenses.data?.items.map(item => choice(item)) ?? []; + const selected = sourceQuery.data ? choice(sourceQuery.data) : undefined; + + return { + empty: Boolean( + !isEditing && expenses.data && expenses.data.items.length === 0, + ), + error: expenses.isError ? expenses.error : null, + loading: expenses.isLoading, + nextDisabled: !expenses.data?.hasNext || expenses.isFetching, + onNext: () => setPage(page + 1), + onPrevious: () => setPage(page - 1), + options: selected && options.every(item => item.value !== selected.value) + ? [selected, ...options] + : options, + page, + previousDisabled: page === 1 || expenses.isFetching, + retry: () => void expenses.refetch(), + sourceId, + sourceQuery, + }; +} + +export { useRefundPurchase }; diff --git a/src/pages/cards/ui/CardsPage/CardsPage.test.tsx b/src/pages/cards/ui/CardsPage/CardsPage.test.tsx index 28a683a..bc6793d 100644 --- a/src/pages/cards/ui/CardsPage/CardsPage.test.tsx +++ b/src/pages/cards/ui/CardsPage/CardsPage.test.tsx @@ -9,7 +9,7 @@ import { } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { MemoryRouter } from "react-router"; -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; @@ -62,9 +62,9 @@ describe("CardsPage", () => { it("preserves exact negative opening amount and the creation intent through unknown failure", async () => { renderCards(); await screen.findByRole("heading", { name: "Добавьте первую карту" }); - fireEvent.click( - screen.getAllByRole("button", { name: "Добавить карту" })[0], - ); + const creationButton = screen.getAllByRole("button", { name: "Добавить карту" })[0]; + assert.isDefined(creationButton); + fireEvent.click(creationButton); const dialog = screen.getByRole("dialog"); const submitted: Record[] = []; let resolve: (() => void) | undefined; @@ -80,7 +80,7 @@ describe("CardsPage", () => { return HttpResponse.error(); } - return HttpResponse.json({ id: submitted[0].id }, { status: 201 }); + return HttpResponse.json({ id: submitted[0]?.id }, { status: 201 }); }), ); fireEvent.change(within(dialog).getByLabelText(/Название карты/), { diff --git a/src/pages/cards/ui/CardsPage/CardsPage.view.tsx b/src/pages/cards/ui/CardsPage/CardsPage.view.tsx index 1b1ea0b..6072698 100644 --- a/src/pages/cards/ui/CardsPage/CardsPage.view.tsx +++ b/src/pages/cards/ui/CardsPage/CardsPage.view.tsx @@ -27,14 +27,12 @@ import { import type { CardsPageVm } from "./CardsPage.vm"; import classes from "./CardsPage.module.css"; -import { useCardsPageViewVm } from "./CardsPageView.vm"; type CardsPageViewProps = { vm: CardsPageVm }; const CardsPageViewCards: FC = (props) => { const { t } = useTranslation(); const { vm } = props; - const { onAddCard } = useCardsPageViewVm(props); if (vm.items) { if (vm.items.length > 0) { @@ -59,7 +57,7 @@ const CardsPageViewCards: FC = (props) => { return ( {t("bank.addCard")}} + action={} text={text} title={t("bank.empty")} /> @@ -77,19 +75,13 @@ const CardsPageViewCards: FC = (props) => { ); } + + return null; }; const CardsPageView: FC = (props) => { - const { - onAddCard, - onArchiveChange, - onCategories, - retryCards, - retryCategories, - retrySummary, - t, - vm, - } = useCardsPageViewVm(props); + const { vm } = props; + const { t } = useTranslation(); return ( @@ -98,7 +90,7 @@ const CardsPageView: FC = (props) => { = (props) => { @@ -119,7 +111,7 @@ const CardsPageView: FC = (props) => { {t(vm.items ? "bank.refreshError" : "bank.error")} - @@ -134,7 +126,7 @@ const CardsPageView: FC = (props) => { - - ); + if (vm.stockState.loading || !vm.stock) return ( + + ); return ; }; diff --git a/src/pages/stock/ui/StockPage/StockPage.view.tsx b/src/pages/stock/ui/StockPage/StockPage.view.tsx index 71fa99d..42ba5af 100644 --- a/src/pages/stock/ui/StockPage/StockPage.view.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.view.tsx @@ -2,36 +2,23 @@ import type { FC } from "react"; import { Alert, - Avatar, - Badge, - Button, - Group, - Paper, - Skeleton, - Stack, Text, - Title, } from "@mantine/core"; -import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { + InstrumentHeader, + InstrumentPage, InstrumentChart as StockChart, InstrumentEvents as StockEvents, InstrumentPosition as StockPosition, - InstrumentSection as StockSection, } from "@/features/instrument-details"; -import { - formatDate, - formatDecimal, - formatMoney, - formatSnapshotTime, -} from "@/shared/lib"; import type { StockPageVm } from "./StockPage.vm"; -import { StockDividends } from "./components/StockEvents.view"; -import classes from "./StockPage.module.css"; +import { StockCompany } from "./components/StockCompany.view"; +import { StockDividends } from "./components/StockDividends.view"; +import { StockQuote } from "./components/StockQuote.view"; type StockPageViewProps = { vm: StockPageVm }; @@ -41,152 +28,24 @@ const StockPageView: FC = (props) => { const stock = vm.stock; if (!stock) return; - const appearanceClass = vm.quote?.change?.value.startsWith("-") - ? classes.negative - : classes.positive; - - let quoteContent; - - if (vm.quoteState.loading) { - quoteContent = ( - - - - ); - } - else { - quoteContent = ( -
    - - {formatMoney(vm.quote?.price)} - - - {!!vm.quote?.change && ( - - {vm.quote?.change?.value.startsWith("-") ? "" : "+"} - - {formatMoney(vm.quote.change)} - - {vm.quote.changePercent === null - ? "" - : ` (${formatDecimal(vm.quote.changePercent)} %)`} - - )} - - {vm.quote?.previousCloseDate - ? ( - - {t("stock.changeBase", { - date: formatDate(vm.quote.previousCloseDate), - })} - - ) - : ( - - {t("stock.noComparison")} - - )} - - - - {t(`stock.trading.${vm.quote?.tradingStatus}`, { - defaultValue: t("stock.trading.unknown"), - })} - - - {!!vm.quote?.priceAt && ( - - {t("stock.priceAt", { - date: formatSnapshotTime(vm.quote.priceAt), - })} - - )} - - - {!!vm.quote?.unavailable.length && ( - - {t("stock.partialQuote")} - - )} - - {!!vm.quoteState.refreshing && ( - - {t("accounts.refreshing")} - - )} -
    - ); - } - - let companyMetricsContent; - - if (vm.company) { - if (vm.company.metrics.length > 0) { - companyMetricsContent = ( -
    - {vm.company.metrics.map(metric => ( -
    - - {t(`stock.metrics.${metric.key}`)} - - {metric.period ? ` · ${metric.period}` : ""} - - - - {new Intl.NumberFormat("ru-RU", { - maximumFractionDigits: 2, - notation: - metric.unit === "money" ? "compact" : "standard", - }).format(metric.value)}{" "} - {metricUnit(metric, t("stock.currencyUnknown"))} - -
    - ))} -
    - ); - } - else { - companyMetricsContent = {t("stock.noFundamentals")}; - } - } + const subtitle = ( + <> + {stock.ticker} ·{" "} + {t(`stock.shareTypes.${stock.shareType}`, { + defaultValue: t("stock.share"), + })} + + ); return ( - - - -
    - - {stock.name[0]} - - -
    - - {stock.name} - - - - {stock.ticker} ·{" "} - {t(`stock.shareTypes.${stock.shareType}`, { - defaultValue: t("stock.share"), - })} - -
    -
    - - -
    + + {!!vm.company?.description && ( @@ -200,75 +59,29 @@ const StockPageView: FC = (props) => { )} - {quoteContent} + - - - {!!stock.sector && ( - - {t("stock.sector")}:{" "} - {t(`stock.sectors.${stock.sector}`, { - defaultValue: stock.sector, - })} - - )} - - {stock.lot !== null && {t("stock.lot", { value: stock.lot })}} - - - {!!vm.company && ( - - {vm.company.unavailable.length > 0 && ( - - - {t("stock.partialCompany")} - - - - - )} - - {companyMetricsContent} - - - {t("stock.periodNote")} - - - )} - -
    + + - + -
    + ); }; -function metricUnit( - metric: NonNullable["metrics"][number], - unknownCurrency: string, -) { - if (metric.unit === "money") - return metric.currency === "RUB" - ? "₽" - : (metric.currency ?? unknownCurrency); - - return metric.unit === "percent" ? "%" : "×"; -} - export { StockPageView }; diff --git a/src/pages/stock/ui/StockPage/StockPage.vm.ts b/src/pages/stock/ui/StockPage/StockPage.vm.ts index fa3bd6d..77c418f 100644 --- a/src/pages/stock/ui/StockPage/StockPage.vm.ts +++ b/src/pages/stock/ui/StockPage/StockPage.vm.ts @@ -1,9 +1,7 @@ -import { useParams, useSearchParams } from "react-router"; - -import type { OperationCategory } from "@/entities/broker-operation"; +import { useParams } from "react-router"; +import { useBrokerageOperationHistory } from "@/entities/broker-operation"; import { - type StockPeriod, useStock, useStockCandles, useStockCompany, @@ -13,23 +11,12 @@ import { } from "@/entities/stock"; import { section, - useInstrumentOperations, + toInstrumentEventRows, + useInstrumentParameters, } from "@/features/instrument-details"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; -import { formatDate, instrumentLogoUrl } from "@/shared/lib"; - -const periods: StockPeriod[] = ["month", "halfYear", "year", "all"]; -const filters = ["all", "trades", "dividends", "other"] as const; - -type StockEventFilter = (typeof filters)[number]; - -const categories: Record = { - all: [], - dividends: ["income"], - other: ["tax", "fee", "transfer", "other"], - trades: ["trade"], -}; +import { compareDateDesc, instrumentLogoUrl } from "@/shared/lib"; type StockPageVm = ReturnType; @@ -37,13 +24,9 @@ function useStockPageVm() { const { stockId = "" } = useParams(); const stock = useStock(stockId); const uid = stock.data?.instrumentUid ?? ""; - const [parameters, setParameters] = useSearchParams(); - const period - = periods.find(value => value === parameters.get("period")) ?? "month"; - - const filter - = filters.find(value => value === parameters.get("events")) ?? "all"; + const { categories, filter, period, search, setFilter, setPeriod } + = useInstrumentParameters("dividends"); const quote = useStockQuote(uid); const candles = useStockCandles(uid, period); @@ -51,12 +34,11 @@ function useStockPageVm() { const company = useStockCompany(uid); const dividends = useStockDividends(uid); - const history = useInstrumentOperations( - uid, - filter, - // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - categories[filter], - ); + const history = useBrokerageOperationHistory(uid ? "all" : undefined, { + categories, + instrumentUid: uid, + limit: 20, + }); const { operations } = history; @@ -64,9 +46,7 @@ function useStockPageVm() { { accountId: "all", candlePeriod: period, - categories: - // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - categories[filter], + categories, instrumentUid: uid, limit: 20, screen: "stock", @@ -74,63 +54,20 @@ function useStockPageVm() { [stock, quote, candles, position, company, dividends, operations], ); - const setParameter = (key: string, value: string) => { - const next = new URLSearchParams(parameters); - next.set(key, value); - setParameters(next, { replace: true }); - }; - - const chartData - = candles.data?.candles.map(candle => ({ - ...candle, - date: formatDate(candle.time), - price: Number(candle.close), - })) ?? []; - - const average = position.data?.averagePurchasePrice; - - const averageLine - = average && average.currency === candles.data?.currency - ? Number(average.value) - : null; - - const companyData = company.data; - const search = parameters.size > 0 ? `?${parameters.toString()}` : ""; - return { - averageLine: - averageLine !== null && Number.isFinite(averageLine) - ? averageLine - : null, candles: candles.data, candlesState: section(candles, sync.retry), canonicalTo: stock.data?.slug && stock.data.slug !== stockId ? `/stocks/${encodeURIComponent(stock.data.slug)}${search}` : null, - chartData, code: apiErrorCode(stock.error), - company: companyData, + company: company.data, companyState: section(company, sync.retry), dividends: dividends.data, dividendsState: section(dividends, sync.retry), - eventRows: - operations.data?.items.map(item => ({ - ...item, - logoName: - item.logoName - ?? stock.data?.logoName - - ?? null, - ticker: - item.ticker - ?? stock.data?.ticker - - ?? null, - })) ?? [], + eventRows: toInstrumentEventRows(operations.data?.items, stock.data), filter, - hasIncompleteCandle: - candles.data?.candles.some(candle => !candle.isComplete) ?? false, hasPrevious: history.hasPrevious, logoSrc: instrumentLogoUrl(stock.data?.logoName), onNext: history.onNext, @@ -144,30 +81,25 @@ function useStockPageVm() { dividends.data?.dividends .filter(event => event.timing !== "upcoming") .toSorted((a, b) => - (b.recordDate ?? b.paymentDate ?? "").localeCompare( - a.recordDate ?? a.paymentDate ?? "", - ), + compareDateDesc(a.paymentDate, b.paymentDate), ) ?? [], period, position: position.data, positionState: section(position, sync.retry), - priceRows: chartData.toReversed(), quote: quote.data, quoteState: section(quote, sync.retry), refresh: sync.refresh, refreshError: sync.transportError || sync.sourceError, refreshing: sync.refreshing, - setFilter: (value: string) => setParameter("events", value), - setPeriod: (value: string) => setParameter("period", value), + setFilter, + setPeriod, stock: stock.data, stockState: section(stock, uid ? sync.retry : () => stock.refetch()), upcoming: dividends.data?.dividends .filter(event => event.timing === "upcoming") .toSorted((a, b) => - (a.recordDate ?? a.paymentDate ?? "").localeCompare( - b.recordDate ?? b.paymentDate ?? "", - ), + compareDateDesc(b.paymentDate, a.paymentDate), ) ?? [], }; } diff --git a/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx b/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx new file mode 100644 index 0000000..df67069 --- /dev/null +++ b/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx @@ -0,0 +1,120 @@ +import type { FC } from "react"; + +import { + Alert, + Button, + Group, + Stack, + Text, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +import { + InstrumentMetric, + InstrumentMetrics, + InstrumentSection as StockSection, +} from "@/features/instrument-details"; + +import type { StockPageVm } from "../StockPage.vm"; + +const compactMetricFormatter = new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: 2, + notation: "compact", +}); + +const metricFormatter = new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: 2, + notation: "standard", +}); + +type StockCompanyProps = Pick & { + stock: NonNullable; +}; + +const StockCompany: FC = (props) => { + const { stock } = props; + const { t } = useTranslation(); + let companyMetricsContent; + + if (props.company) { + if (props.company.metrics.length > 0) { + companyMetricsContent = ( + + {props.company.metrics.map((metric) => { + const label = [t(`stock.metrics.${metric.key}`), metric.period].filter(Boolean).join(" · "); + const formatter = metric.unit === "money" ? compactMetricFormatter : metricFormatter; + const value = `${formatter.format(metric.value)} ${metricUnit(metric, t("stock.currencyUnknown"))}`; + + return ( + + ); + })} + + ); + } + else { + companyMetricsContent = {t("stock.noFundamentals")}; + } + } + + return ( + + + {!!stock.sector && ( + + {t("stock.sector")}:{" "} + {t(`stock.sectors.${stock.sector}`, { + defaultValue: stock.sector, + })} + + )} + + {stock.lot !== null && {t("stock.lot", { value: stock.lot })}} + + + {!!props.company && ( + + {props.company.unavailable.length > 0 && ( + + + {t("stock.partialCompany")} + + + + + )} + + {companyMetricsContent} + + + {t("stock.periodNote")} + + + )} + + ); +}; + +function metricUnit( + metric: NonNullable["metrics"][number], + unknownCurrency: string, +) { + if (metric.unit === "money") + return metric.currency === "RUB" + ? "₽" + : (metric.currency ?? unknownCurrency); + + return metric.unit === "percent" ? "%" : "×"; +} + +export { StockCompany }; diff --git a/src/pages/stock/ui/StockPage/components/StockEvents.view.tsx b/src/pages/stock/ui/StockPage/components/StockDividends.view.tsx similarity index 80% rename from src/pages/stock/ui/StockPage/components/StockEvents.view.tsx rename to src/pages/stock/ui/StockPage/components/StockDividends.view.tsx index 780c6d1..ac54370 100644 --- a/src/pages/stock/ui/StockPage/components/StockEvents.view.tsx +++ b/src/pages/stock/ui/StockPage/components/StockDividends.view.tsx @@ -11,25 +11,30 @@ import { } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { InstrumentSection as StockSection } from "@/features/instrument-details"; -import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; +import type { StockDividends as Dividends } from "@/entities/stock"; -import type { StockPageVm } from "../StockPage.vm"; +import { InstrumentSection, type SectionState } from "@/features/instrument-details"; +import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; import classes from "../StockPage.module.css"; -type StockEventsProps = { vm: StockPageVm }; +type StockDividendsProps = { + dividends?: Dividends; + pastDividends: Dividends["dividends"]; + state: SectionState; + upcoming: Dividends["dividends"]; +}; -const StockDividends: FC = (props) => { - const { vm } = props; +const StockDividends: FC = (props) => { + const { dividends, pastDividends, state, upcoming } = props; const { t } = useTranslation(); let upcomingContent; - if (vm.dividends) { - if (vm.upcoming.length > 0) { + if (dividends) { + if (upcoming.length > 0) { upcomingContent = ( - vm.upcoming.map(event => ( + upcoming.map(event => (
    = (props) => { else { upcomingContent = ( - {t("stock.noUpcoming", { to: formatDate(vm.dividends.to) })} + {t("stock.noUpcoming", { to: formatDate(dividends.to) })} ); } @@ -112,10 +117,10 @@ const StockDividends: FC = (props) => { let pastDividendsContent; - if (vm.pastDividends.length > 0) { + if (pastDividends.length > 0) { pastDividendsContent = ( - {vm.pastDividends.map(event => ( + {pastDividends.map(event => ( = (props) => { } return ( - - {!!vm.dividends && ( + + {!!dividends && ( {!!( - vm.dividends.estimateUnavailable || vm.dividends.positionIsStale + dividends.estimateUnavailable || dividends.positionIsStale ) && ( {t("stock.estimateUnavailable")} @@ -183,10 +188,10 @@ const StockDividends: FC = (props) => { {t("stock.dividendNote")} - {!!vm.dividends.positionAt && ( + {!!dividends.positionAt && ( {t("stock.snapshotAt", { - date: formatSnapshotTime(vm.dividends.positionAt), + date: formatSnapshotTime(dividends.positionAt), })} )} @@ -200,8 +205,8 @@ const StockDividends: FC = (props) => { {t("stock.dividendWindow", { - from: formatDate(vm.dividends.from), - to: formatDate(vm.dividends.to), + from: formatDate(dividends.from), + to: formatDate(dividends.to), })} @@ -211,7 +216,7 @@ const StockDividends: FC = (props) => { )} - + ); }; diff --git a/src/pages/stock/ui/StockPage/components/StockQuote.view.tsx b/src/pages/stock/ui/StockPage/components/StockQuote.view.tsx new file mode 100644 index 0000000..5c9a7b7 --- /dev/null +++ b/src/pages/stock/ui/StockPage/components/StockQuote.view.tsx @@ -0,0 +1,98 @@ +import type { FC } from "react"; + +import { + Group, + Skeleton, + Text, +} from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; +import { useTranslation } from "react-i18next"; + +import { + InstrumentPrice, + InstrumentQuoteStatus, +} from "@/features/instrument-details"; +import { + formatDate, + formatDecimal, + formatMoney, +} from "@/shared/lib"; + +import type { StockPageVm } from "../StockPage.vm"; + +import classes from "../StockPage.module.css"; + +type StockQuoteProps = Pick; + +const StockQuote: FC = (props) => { + const { t } = useTranslation(); + + const appearanceClass = props.quote?.change?.value.startsWith("-") + ? classes.negative + : classes.positive; + + let quoteContent; + + if (props.quoteState.loading) { + quoteContent = ( + + + + ); + } + else { + quoteContent = ( +
    + + {formatMoney(props.quote?.price)} + + + {!!props.quote?.change && ( + + {props.quote.change.value.startsWith("-") ? "" : "+"} + + {formatMoney(props.quote.change)} + + {props.quote.changePercent === null + ? "" + : ` (${formatDecimal(props.quote.changePercent)} %)`} + + )} + + {props.quote?.previousCloseDate + ? ( + + {t("stock.changeBase", { + date: formatDate(props.quote.previousCloseDate), + })} + + ) + : ( + + {t("stock.noComparison")} + + )} + + + + + + {!!props.quote?.unavailable.length && ( + + {t("stock.partialQuote")} + + )} + + {!!props.quoteState.refreshing && ( + + {t("accounts.refreshing")} + + )} +
    + ); + } + + return quoteContent; +}; + +export { StockQuote }; diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.module.css b/src/pages/stocks/ui/StocksPage/StocksPage.module.css deleted file mode 100644 index ab07ae0..0000000 --- a/src/pages/stocks/ui/StocksPage/StocksPage.module.css +++ /dev/null @@ -1,36 +0,0 @@ -.card { - display: grid; - grid-template-columns: auto minmax(0, 1fr) minmax(150px, 210px); - gap: var(--mantine-spacing-md); - align-items: start; - padding: var(--mantine-spacing-lg); - border-radius: var(--mantine-radius-lg); - color: inherit; - text-decoration: none; -} -.card:hover { - border-color: var(--mantine-color-blue-4); - background: var(--mantine-color-blue-0); -} -.card:focus-visible { - outline: 2px solid var(--mantine-color-blue-6); - outline-offset: 3px; -} -.identity { - min-width: 0; - overflow-wrap: anywhere; -} -.quote { - text-align: right; - font-variant-numeric: tabular-nums; -} -@media (max-width: 40em) { - .card { - grid-template-columns: auto minmax(0, 1fr); - padding: var(--mantine-spacing-md); - } - .quote { - grid-column: 2; - text-align: left; - } -} diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx b/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx index 522b16b..1b6ae25 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx +++ b/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx @@ -1,9 +1,9 @@ import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { MemoryRouter } from "react-router"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; @@ -12,6 +12,8 @@ import { server } from "@/test/server"; import { StocksPage } from "./StocksPage"; +afterEach(() => vi.useRealTimers()); + describe("stock search", () => { it("waits for a meaningful query and uses the server-verified ticker link", async () => { const query = vi.fn(); @@ -44,6 +46,7 @@ describe("stock search", () => { clear: vi.fn(), refresh: () => Promise.resolve(false), }); + vi.useFakeTimers(); render( @@ -53,10 +56,16 @@ describe("stock search", () => { , ); + const input = screen.getByLabelText("Поиск акций"); + fireEvent.change(input, { target: { value: "S" } }); + await act(() => vi.advanceTimersByTimeAsync(300)); expect(query).not.toHaveBeenCalled(); - fireEvent.change(screen.getByLabelText("Поиск акций"), { - target: { value: "SBER" }, - }); + fireEvent.change(input, { target: { value: "SB" } }); + await act(() => vi.advanceTimersByTimeAsync(299)); + expect(query).not.toHaveBeenCalled(); + // The final input uses real timers for network and query notifications. + vi.useRealTimers(); + fireEvent.change(input, { target: { value: "SBER" } }); expect( await screen.findByRole("link", { name: /Сбербанк/ }), ).toHaveAttribute("href", "/stocks/SBER"); @@ -64,6 +73,6 @@ describe("stock search", () => { expect(screen.getByText("Режим: TQBR")).toBeVisible(); expect(screen.getByText("ISIN: RU0009029540")).toBeVisible(); expect(screen.getByText("Мосбиржа")).toBeVisible(); - await waitFor(() => expect(query).toHaveBeenCalledWith("SBER")); + await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith("SBER")); }); }); diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx index edcb503..6056e71 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx +++ b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx @@ -1,202 +1,89 @@ import type { FC } from "react"; -import { - Alert, - Anchor, - Avatar, - Badge, - Button, - Group, - Paper, - Skeleton, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { LoadingSkeleton } from "@wealthledger/ui"; +import { Badge, Group, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { Link } from "react-router"; -import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; +import { + InstrumentSearch, + InstrumentSearchCard, +} from "@/features/instrument-search"; import { formatMoney, formatSnapshotTime } from "@/shared/lib"; import type { StocksPageVm } from "./StocksPage.vm"; -import classes from "./StocksPage.module.css"; - type StocksPageViewProps = { vm: StocksPageVm }; -const StocksPageViewResults: FC = (props) => { - const { t } = useTranslation(); - const { vm } = props; - - if (vm.loading) { - return ( - - - - - - - - - - ); - } - - if (!vm.ready) return; - - if (vm.items.length > 0) { - return ( - - - {t("stock.searchVariants")} - - - {vm.items.map(stock => ( - - - {stock.name[0]} - - -
    - - {stock.name} - - - - {stock.ticker} ·{" "} - {t(`stock.shareTypes.${stock.shareType}`, { - defaultValue: t("stock.share"), - })} - - - - - {t(`stock.exchanges.${stock.realExchange}`, { - defaultValue: t("stock.exchangeUnknown"), - })} - - - {!!stock.classCode && ( - - {t("stock.classCode", { code: stock.classCode })} - - )} - - - {!!stock.isin && ( - - {t("stock.isin", { value: stock.isin })} - - )} - - {stock.apiTradeAvailable === false && ( - - {t("stock.apiUnavailable")} - - )} -
    - -
    - - {stock.price ? formatMoney(stock.price) : "—"} - - - - {stock.price && stock.priceAt - ? t("stock.priceAt", { - date: formatSnapshotTime(stock.priceAt), - }) - : t("stock.priceUnavailable")} - - - {stock.apiTradeAvailable === true && ( - - {t("stock.apiAvailable")} - - )} -
    -
    - ))} -
    - ); - } - - return ( - - {t(vm.partial ? "investment.partial" : "stock.noSearchResults")} - - ); -}; - const StocksPageView: FC = (props) => { const { vm } = props; const { t } = useTranslation(); return ( - -
    - {t("stock.searchTitle")} - - - {t("stock.searchSubtitle")} - -
    - - - - {!!vm.partial && {t("investment.partial")}} - - - - {!vm.canSearch && {t("stock.searchHint")}} - - {!!vm.error && ( - - - - {t( - isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) - || isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken) - ? "stock.connect" - : "stock.sectionError", - )} + + {vm.items.map((stock) => { + const quote = ( + <> + + {stock.price ? formatMoney(stock.price) : "—"} - - {t("accounts.openSettings")} - + + {stock.price && stock.priceAt + ? t("stock.priceAt", { + date: formatSnapshotTime(stock.priceAt), + }) + : t("stock.priceUnavailable")} + - - - - )} + {stock.apiTradeAvailable === true && ( + + {t("stock.apiAvailable")} + + )} + + ); - -
    + return ( + + + {stock.ticker} ·{" "} + {t(`stock.shareTypes.${stock.shareType}`, { + defaultValue: t("stock.share"), + })} + + + + + {t(`stock.exchanges.${stock.realExchange}`, { + defaultValue: t("stock.exchangeUnknown"), + })} + + + {!!stock.classCode && ( + + {t("stock.classCode", { code: stock.classCode })} + + )} + + + {!!stock.isin && ( + + {t("stock.isin", { value: stock.isin })} + + )} + + {stock.apiTradeAvailable === false && ( + + {t("stock.apiUnavailable")} + + )} + + ); + })} + ); }; diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts index 5c6520c..a35e22e 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts +++ b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts @@ -1,25 +1,16 @@ -import type { ChangeEvent } from "react"; - -import { useDebouncedValue } from "@mantine/hooks"; -import { useSearchParams } from "react-router"; - import { MIN_STOCK_SEARCH_LENGTH, useStockSearch } from "@/entities/stock"; +import { useInstrumentSearch } from "@/features/instrument-search"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; import { instrumentLogoUrl } from "@/shared/lib"; -const SEARCH_DEBOUNCE_MS = 300; - type StocksPageVm = ReturnType; function useStocksPageVm() { - const [parameters, setParameters] = useSearchParams(); - const query = parameters.get("q") ?? ""; - const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS); + const search = useInstrumentSearch(MIN_STOCK_SEARCH_LENGTH); + const { canSearch, debounced, isWaiting, query } = search; const result = useStockSearch(debounced); const sync = useScreenSync({ query: debounced, screen: "stocks" }, [result]); - const canSearch = query.trim().length >= MIN_STOCK_SEARCH_LENGTH; - const isWaiting = query.trim() !== debounced; return { canSearch, @@ -29,7 +20,7 @@ function useStocksPageVm() { result.data?.map(stock => ({ ...stock, logoSrc: instrumentLogoUrl(stock.logoName), - to: `/stocks/${encodeURIComponent(stock.slug ?? stock.instrumentUid)}`, + to: `/stocks/${encodeURIComponent(stock.slug)}`, })) ?? [], loading: canSearch @@ -37,10 +28,7 @@ function useStocksPageVm() { || (!result.data && !result.isError && result.meta?.sync.state !== "failed")), - onQueryChange: (event: ChangeEvent) => { - const q = event.currentTarget.value; - setParameters(q ? { q } : {}, { replace: true }); - }, + onQueryChange: search.onQueryChange, partial: result.meta?.availability === "partial", query, ready: diff --git a/src/shared/api/authenticated-query.test.tsx b/src/shared/api/authenticated-query.test.tsx index 3af7a5f..4699a91 100644 --- a/src/shared/api/authenticated-query.test.tsx +++ b/src/shared/api/authenticated-query.test.tsx @@ -51,7 +51,7 @@ describe("useAuthenticatedQuery", () => { await new Promise(resolve => setTimeout(resolve, 0)); expect(requests).not.toHaveBeenCalled(); - expect(result.current.fetchStatus).toBe("idle"); + expect(result.current?.fetchStatus).toBe("idle"); configureAuthClient({ accessToken: () => "token", @@ -59,9 +59,9 @@ describe("useAuthenticatedQuery", () => { refresh: () => Promise.resolve(true), }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current?.isSuccess).toBe(true)); expect(requests).toHaveBeenCalledOnce(); - expect(result.current.data?.data).toEqual([ + expect(result.current?.data?.data).toEqual([ { id: "account-1", name: "Основной", diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index a2d2b84..49b5425 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -13,6 +13,6 @@ export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; export { invalidateResourceAccess, - unwrapResourceResult, + type InvestmentResourceMeta, useResourceQuery, } from "./resource-query"; diff --git a/src/entities/portfolio/api/resource-lifecycle.test.tsx b/src/shared/api/resource-query.test.tsx similarity index 87% rename from src/entities/portfolio/api/resource-lifecycle.test.tsx rename to src/shared/api/resource-query.test.tsx index 6dcea08..9ccbc2a 100644 --- a/src/entities/portfolio/api/resource-lifecycle.test.tsx +++ b/src/shared/api/resource-query.test.tsx @@ -7,11 +7,17 @@ import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; -import { - type Portfolio, - portfolioQueryOptions, - usePortfolio, -} from "./usePortfolio"; +import { apiQueryClient, type paths, useResourceQuery } from "./index"; + +type Portfolio = NonNullable; + +function resourceOptions(accountId: string) { + return apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts/{accountId}/portfolio", { params: { path: { accountId } } }); +} + +function usePrivateResource(accountId: string) { + return useResourceQuery({ ...resourceOptions(accountId), meta: { accessGroup: "brokerage" } }); +} const snapshot: Portfolio = { cashBalances: [], @@ -52,7 +58,7 @@ describe("persistent resource query lifecycle", () => { ), ); const { queryClient, wrapper } = createQueryWrapper(); - const { result } = renderHook(() => usePortfolio("a"), { wrapper }); + const { result } = renderHook(() => usePrivateResource("a"), { wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); await act(() => result.current.observe("new")); await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new")); @@ -60,7 +66,7 @@ describe("persistent resource query lifecycle", () => { expect(modes).toEqual([null, "observe", "observe"]); expect(queryClient.getQueryCache().getAll()).toHaveLength(1); expect( - queryClient.getQueryData(portfolioQueryOptions("a").queryKey), + queryClient.getQueryData(resourceOptions("a").queryKey), ).toMatchObject({ data: snapshot, meta: { stateVersion: "new" } }); }); it("does not convert missing or failed source data to empty financial success", async () => { @@ -79,7 +85,7 @@ describe("persistent resource query lifecycle", () => { ), ); - const { result } = renderHook(() => usePortfolio("a"), { + const { result } = renderHook(() => usePrivateResource("a"), { wrapper: createQueryWrapper().wrapper, }); @@ -109,7 +115,7 @@ describe("persistent resource query lifecycle", () => { ), ); - const { result } = renderHook(() => usePortfolio("a"), { + const { result } = renderHook(() => usePrivateResource("a"), { wrapper: createQueryWrapper().wrapper, }); @@ -186,7 +192,7 @@ describe("persistent resource query lifecycle", () => { const { wrapper } = createQueryWrapper(); const { result } = renderHook( - () => ({ a: usePortfolio("a"), b: usePortfolio("b") }), + () => ({ a: usePrivateResource("a"), b: usePrivateResource("b") }), { wrapper }, ); diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts index 388da96..6a84724 100644 --- a/src/shared/api/resource-query.ts +++ b/src/shared/api/resource-query.ts @@ -1,14 +1,13 @@ import { type QueryClient, type QueryFunctionContext, - type QueryKey, useQueryClient, type UseQueryOptions, type UseQueryResult, } from "@tanstack/react-query"; import { useEffect } from "react"; -import type { components } from "./generated/schema"; +import type { components, paths } from "./generated/schema"; import { useAuthenticatedQuery } from "./authenticated-query"; import { getAuthenticatedSessionSnapshot } from "./client"; @@ -16,11 +15,26 @@ import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; const accessEpochs = new WeakMap>(); +type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"]; + type Resource = { data: unknown; - meta: components["schemas"]["InvestmentResourceMetaDto"]; + meta: InvestmentResourceMeta; }; +type ResourcePath = { + [P in keyof paths]: paths[P]["get"] extends { + // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- HTTP success response in the generated contract. + responses: { 200: { content: { "application/json": Resource } } }; + } ? P : never; +}[keyof paths]; + +type ResourceQueryKey = readonly [ + "get", + ResourcePath, + { params?: { path?: object; query?: { sync?: "observe" } } }?, +]; + function invalidateResourceAccess( client: QueryClient, group: string, @@ -56,6 +70,17 @@ function invalidateResourceAccess( }); } +// Keep endpoint and parameters intact. The generated queryFn accepts the +// optional sync mode even for a cache key originally created without init. +function observationKey(key: K): K { + const [method, path, init] = key; + + return [method, path, { + ...init, + params: { ...init?.params, query: { ...init?.params?.query, sync: "observe" } }, + }] as unknown as K; +} + function unwrapResourceResult( result: UseQueryResult, ) { @@ -84,7 +109,7 @@ function unwrapResourceResult( } // Observation changes transport mode, never the generated cache identity. -function useResourceQuery( +function useResourceQuery( options: UseQueryOptions, ) { const client = useQueryClient(); @@ -135,23 +160,7 @@ function useResourceQuery( return client.query({ ...options, queryFn: async (context: QueryFunctionContext) => { - const [method, path, input] = context.queryKey; - - const init = input as - undefined - | { params?: { query?: Record } }; - - const queryKey = [ - method, - path, - { - ...init, - params: { - ...init?.params, - query: { ...init?.params?.query, sync: "observe" }, - }, - }, - ] as unknown as K; + const queryKey = observationKey(context.queryKey); const response = await queryFunction({ ...context, queryKey }); @@ -183,4 +192,8 @@ function useResourceQuery( }; } -export { invalidateResourceAccess, unwrapResourceResult, useResourceQuery }; +export { + invalidateResourceAccess, + type InvestmentResourceMeta, + useResourceQuery, +}; diff --git a/src/shared/lib/calendar.test.ts b/src/shared/lib/calendar.test.ts new file mode 100644 index 0000000..d27959b --- /dev/null +++ b/src/shared/lib/calendar.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { compareDateDesc, isValidPeriod, moscowDate } from "./calendar"; +import { calendarDate } from "./calendar.schema"; + +describe("calendar dates", () => { + it.each([ + ["0000-02-29", true], + ["0001-01-01", true], + ["0099-12-31", true], + ["1900-02-29", false], + ["2000-02-29", true], + ["2024-02-29", true], + ["2026-02-29", false], + ["2026-99-99", false], + ["2026-1-01", false], + ["", false], + ])("validates %s without Date normalization", (value, valid) => { + expect(calendarDate.safeParse(value).success).toBe(valid); + }); + + it("accepts open or single-day periods and rejects reversed ranges", () => { + expect(isValidPeriod(null, null)).toBe(true); + expect(isValidPeriod(null, "2026-09-01")).toBe(true); + expect(isValidPeriod("2026-09-01", null)).toBe(true); + expect(isValidPeriod("2026-09-01", "2026-09-01")).toBe(true); + expect(isValidPeriod("2026-09-02", "2026-09-01")).toBe(false); + expect(isValidPeriod("", "2026-09-01")).toBe(false); + }); + + it("changes the calendar day at Moscow midnight across a year boundary", () => { + expect(moscowDate(new Date("2026-12-31T20:59:59Z"))).toBe("2026-12-31"); + expect(moscowDate(new Date("2026-12-31T21:00:00Z"))).toBe("2027-01-01"); + }); + + it("compares dates newest first and keeps dateless rows last", () => { + const dates = [null, "2026-01-01", "2026-03-01", "2026-02-01"]; + + expect(dates.toSorted(compareDateDesc)).toEqual([ + "2026-03-01", + "2026-02-01", + "2026-01-01", + null, + ]); + + expect(dates.toSorted((a, b) => compareDateDesc(b, a))).toEqual([ + null, + "2026-01-01", + "2026-02-01", + "2026-03-01", + ]); + }); +}); diff --git a/src/shared/lib/calendar.ts b/src/shared/lib/calendar.ts index 997b5c8..c3697b3 100644 --- a/src/shared/lib/calendar.ts +++ b/src/shared/lib/calendar.ts @@ -9,4 +9,26 @@ function isValidPeriod(from: null | string, to: null | string) { ); } -export { isValidPeriod }; +const moscowFormatter = new Intl.DateTimeFormat("en-CA", { + day: "2-digit", + month: "2-digit", + timeZone: "Europe/Moscow", + year: "numeric", +}); + +// Newest first. Missing dates sort last in both directions, so a row without a +// date never jumps to the top of a "past" list. +function compareDateDesc(a: null | string, b: null | string) { + return (b ?? "").localeCompare(a ?? ""); +} + +function moscowDate(value: Date): string { + const parts = moscowFormatter.formatToParts(value); + + const part = (type: string) => + parts.find(item => item.type === type)?.value; + + return `${part("year")}-${part("month")}-${part("day")}`; +} + +export { compareDateDesc, isValidPeriod, moscowDate }; diff --git a/src/shared/lib/display.ts b/src/shared/lib/display.ts index 1e178de..4a23e3f 100644 --- a/src/shared/lib/display.ts +++ b/src/shared/lib/display.ts @@ -7,17 +7,19 @@ type FormatMoneyOptions = { type Money = { currency: string; value: string }; +const dateFormatter = new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + month: "2-digit", + timeZone: "UTC", + year: "numeric", +}); + function formatDate(value: string): string { - return new Intl.DateTimeFormat("ru-RU", { - day: "2-digit", - month: "2-digit", - timeZone: "UTC", - year: "numeric", - }).format(new Date(value)); + return dateFormatter.format(new Date(value)); } function formatDecimal(value: string): string { - const [integer, fraction] = value.split(".", DECIMAL_PART_COUNT); + const [integer = "", fraction] = value.split(".", DECIMAL_PART_COUNT); const sign = integer.startsWith("-") ? "-" : ""; const digits = sign ? integer.slice(1) : integer; const groupSize = 3; @@ -41,7 +43,7 @@ function formatMoney( ): string { if (!money) return "—"; - const [integerPart, fractionPart = ""] = money.value.split( + const [integerPart = "", fractionPart = ""] = money.value.split( ".", DECIMAL_PART_COUNT, ); @@ -59,20 +61,22 @@ function formatMoney( return `${formatDecimal(decimal)} ${currency}`; } +const snapshotFormatter = new Intl.DateTimeFormat("ru-RU", { + dateStyle: "short", + timeStyle: "short", + timeZone: "UTC", +}); + function formatSnapshotTime(value: string): string { - return new Intl.DateTimeFormat("ru-RU", { - dateStyle: "short", - timeStyle: "short", - timeZone: "UTC", - }).format(new Date(value)); + return snapshotFormatter.format(new Date(value)); } const logoSize = 160; +const chartFormatter = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 4 }); + function formatChartValue(value: number) { - return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 4 }).format( - value, - ); + return chartFormatter.format(value); } function instrumentLogoUrl(logoName?: null | string): null | string { diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index d490133..5561391 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,4 +1,4 @@ -export { isValidPeriod } from "./calendar"; +export { compareDateDesc, isValidPeriod, moscowDate } from "./calendar"; export { calendarDate } from "./calendar.schema"; export { formatChartValue, @@ -8,4 +8,4 @@ export { formatSnapshotTime, instrumentLogoUrl, } from "./display"; -export { useImageFallback } from "./useImageFallback"; +export { useUrlPeriod } from "./useUrlPeriod"; diff --git a/src/shared/lib/useImageFallback.ts b/src/shared/lib/useImageFallback.ts deleted file mode 100644 index 2b14d39..0000000 --- a/src/shared/lib/useImageFallback.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useState } from "react"; - -function useImageFallback() { - const [failed, setFailed] = useState(false); - const onError = () => setFailed(true); - - return { failed, onError }; -} - -export { useImageFallback }; diff --git a/src/shared/lib/useUrlPeriod.ts b/src/shared/lib/useUrlPeriod.ts new file mode 100644 index 0000000..0129edd --- /dev/null +++ b/src/shared/lib/useUrlPeriod.ts @@ -0,0 +1,27 @@ +import { useEffect } from "react"; +import { useSearchParams } from "react-router"; + +import { isValidPeriod } from "./calendar"; + +function useUrlPeriod() { + const [parameters, setParameters] = useSearchParams(); + const from = parameters.get("from"); + const to = parameters.get("to"); + const isValid = isValidPeriod(from, to); + useEffect(() => { + if (isValid || (!parameters.has("from") && !parameters.has("to"))) return; + const normalized = new URLSearchParams(parameters); + normalized.delete("from"); + normalized.delete("to"); + setParameters(normalized, { replace: true }); + }, [isValid, parameters, setParameters]); + + return { + from: isValid ? from : null, + parameters, + setParameters, + to: isValid ? to : null, + }; +} + +export { useUrlPeriod }; diff --git a/src/shared/theme/Foundations.stories.tsx b/src/shared/theme/Foundations.stories.tsx index 4419c3d..345d1b7 100644 --- a/src/shared/theme/Foundations.stories.tsx +++ b/src/shared/theme/Foundations.stories.tsx @@ -61,14 +61,13 @@ const ColorsView: FC = () => { ))} - {Object.keys(designTokens.color.palettes).map(palette => ( + {Object.entries(designTokens.color.palettes).map(([palette, colors]) => ( {palette} { - // eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. - theme.colors[palette].map((value, shade) => ( + colors.map((value, shade) => (