chore: finish frontend ESLint migration #18
Enable every plugin recommended preset and replace Biome with ESLint formatting. Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { useAccountPageViewModel } from "./AccountPage.vm";
|
||||
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
|
||||
import { AccountPageError } from "./components/AccountPageError.view";
|
||||
@@ -16,29 +17,29 @@ const AccountPage: FC<AccountPageProps> = (props) => {
|
||||
<AccountPageEmpty
|
||||
account={viewModel.account}
|
||||
accountId={viewModel.accountId}
|
||||
refreshing={viewModel.refreshing}
|
||||
sourceError={viewModel.sourceError}
|
||||
refreshError={viewModel.refreshError}
|
||||
retry={viewModel.retry}
|
||||
recentOperations={viewModel.recentOperations}
|
||||
recentEvents={viewModel.recentEvents}
|
||||
incomeSummary={viewModel.incomeSummary}
|
||||
recentEvents={viewModel.recentEvents}
|
||||
recentOperations={viewModel.recentOperations}
|
||||
refreshError={viewModel.refreshError}
|
||||
refreshing={viewModel.refreshing}
|
||||
retry={viewModel.retry}
|
||||
sourceError={viewModel.sourceError}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<AccountPageLoaded
|
||||
account={viewModel.account}
|
||||
accountId={viewModel.accountId}
|
||||
incomeSummary={viewModel.incomeSummary}
|
||||
partial={viewModel.partial}
|
||||
portfolio={viewModel.portfolio}
|
||||
positionSections={viewModel.positionSections}
|
||||
refreshing={viewModel.refreshing}
|
||||
sourceError={viewModel.sourceError}
|
||||
refreshError={viewModel.refreshError}
|
||||
retry={viewModel.retry}
|
||||
recentOperations={viewModel.recentOperations}
|
||||
recentEvents={viewModel.recentEvents}
|
||||
incomeSummary={viewModel.incomeSummary}
|
||||
recentOperations={viewModel.recentOperations}
|
||||
refreshError={viewModel.refreshError}
|
||||
refreshing={viewModel.refreshing}
|
||||
retry={viewModel.retry}
|
||||
sourceError={viewModel.sourceError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,81 +1,87 @@
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
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 = {
|
||||
lastSuccessfulAt: "2026-08-13T10:00:00Z",
|
||||
isStale: true,
|
||||
totalsByCurrency: [{ currency: "RUB", value: "150" }],
|
||||
positions: [
|
||||
{
|
||||
figi: "FIGI-STOCK",
|
||||
displayName: "Акция",
|
||||
type: "STOCK",
|
||||
quantity: "2",
|
||||
currentValue: { currency: "RUB", value: "100" },
|
||||
averagePurchasePrice: null,
|
||||
absoluteReturn: { currency: "RUB", value: "10" },
|
||||
isin: null,
|
||||
ticker: null,
|
||||
logoName: "akciya.png",
|
||||
},
|
||||
{
|
||||
figi: "FIGI-BOND",
|
||||
displayName: "Облигация",
|
||||
type: "BOND",
|
||||
quantity: "1",
|
||||
currentValue: { currency: "RUB", value: "50" },
|
||||
averagePurchasePrice: { currency: "RUB", value: "45" },
|
||||
absoluteReturn: { currency: "RUB", value: "-5" },
|
||||
isin: "RU000A0JX0J2",
|
||||
ticker: null,
|
||||
logoName: null,
|
||||
},
|
||||
{
|
||||
figi: "FIGI-FUND",
|
||||
displayName: "Фонд",
|
||||
type: "FUND",
|
||||
quantity: "3",
|
||||
currentValue: null,
|
||||
averagePurchasePrice: null,
|
||||
absoluteReturn: null,
|
||||
isin: null,
|
||||
ticker: null,
|
||||
logoName: "fond.png",
|
||||
},
|
||||
],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [{ currency: "USD", value: "1" }],
|
||||
};
|
||||
const portfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
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" }],
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
|
||||
function configureTestAuth() {
|
||||
configureAuthClient({
|
||||
accessToken: () => "test-token",
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(true),
|
||||
});
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const { wrapper: QueryWrapper } = createQueryWrapper();
|
||||
const Wrapper: FC<PropsWithChildren> = (props) => (
|
||||
const Wrapper: FC<PropsWithChildren> = props => (
|
||||
<QueryWrapper>
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={["/accounts/account-1"]}>
|
||||
<Routes>
|
||||
<Route path="/accounts/:accountId" element={props.children} />
|
||||
<Route element={props.children} path="/accounts/:accountId" />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
@@ -91,30 +97,32 @@ function mockLoadedAccountPage() {
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
accountId: "account-1",
|
||||
items: [
|
||||
{
|
||||
id: "operation-1",
|
||||
occurredAt: "2026-08-13T10:00:00Z",
|
||||
type: "Buy",
|
||||
category: "trade",
|
||||
description: "Покупка акции",
|
||||
figi: "FIGI-STOCK",
|
||||
instrumentUid: null,
|
||||
ticker: "STK",
|
||||
instrumentType: "share",
|
||||
payment: { currency: "RUB", value: "-100" },
|
||||
quantity: "1",
|
||||
price: null,
|
||||
accruedInterest: null,
|
||||
commission: null,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
observedAt: "2026-08-13T10:00:00Z",
|
||||
}),
|
||||
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
accountId: "account-1",
|
||||
hasNext: false,
|
||||
items: [
|
||||
{
|
||||
accruedInterest: null,
|
||||
category: "trade",
|
||||
commission: null,
|
||||
description: "Покупка акции",
|
||||
figi: "FIGI-STOCK",
|
||||
id: "operation-1",
|
||||
instrumentType: "share",
|
||||
instrumentUid: null,
|
||||
occurredAt: "2026-08-13T10:00:00Z",
|
||||
payment: { currency: "RUB", value: "-100" },
|
||||
price: null,
|
||||
quantity: "1",
|
||||
ticker: "STK",
|
||||
type: "Buy",
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
observedAt: "2026-08-13T10:00:00Z",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||
@@ -122,16 +130,18 @@ function mockLoadedAccountPage() {
|
||||
resource({
|
||||
accountId: "account-1",
|
||||
calculatedAt: "2026-08-13T10:00:00Z",
|
||||
events: Array.from({ length: 6 }, (_, index) => ({
|
||||
type: "coupon",
|
||||
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
|
||||
dateKind: "event",
|
||||
figi: `FIGI-EVENT-${index}`,
|
||||
instrumentUid: null,
|
||||
ticker: `BND${index}`,
|
||||
displayName: `Купон ${index}`,
|
||||
projectedAmount: { currency: "RUB", value: String(index + 1) },
|
||||
})),
|
||||
events: Array.from({ length: 6 }, (_, index) => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
|
||||
dateKind: "event",
|
||||
displayName: `Купон ${index}`,
|
||||
figi: `FIGI-EVENT-${index}`,
|
||||
instrumentUid: null,
|
||||
projectedAmount: { currency: "RUB", value: String(index + 1) },
|
||||
ticker: `BND${index}`,
|
||||
type: "coupon",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */))),
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -150,38 +160,40 @@ describe("useAccountPageViewModel", () => {
|
||||
] as const;
|
||||
const snapshot: Portfolio = {
|
||||
...portfolio,
|
||||
totalsByCurrency: [{ currency: "RUB", value: "1250" }],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [{ currency: "RUB", value: "1000" }],
|
||||
positions: [
|
||||
...portfolio.positions,
|
||||
{
|
||||
...portfolio.positions[0],
|
||||
currentValue: { currency: "RUB", value: "50" },
|
||||
figi: "FIGI-CURRENCY",
|
||||
type: "CURRENCY",
|
||||
currentValue: { currency: "RUB", value: "50" },
|
||||
},
|
||||
...otherTypes.map((type) => ({
|
||||
...portfolio.positions[2],
|
||||
figi: `FIGI-${type}-NEXT`,
|
||||
type,
|
||||
currentValue:
|
||||
...otherTypes.map(type => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
...portfolio.positions[2],
|
||||
currentValue:
|
||||
type === "OPTION" ? { currency: "RUB", value: "50" } : null,
|
||||
})),
|
||||
figi: `FIGI-${type}-NEXT`,
|
||||
type,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */))),
|
||||
],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
totalsByCurrency: [{ currency: "RUB", value: "1250" }],
|
||||
};
|
||||
const original = structuredClone(snapshot);
|
||||
|
||||
const sections = toAccountPagePositionSections(snapshot);
|
||||
|
||||
expect(sections.map((section) => section.kind)).toEqual([
|
||||
expect(sections.map(section => section.kind)).toEqual([
|
||||
"stocks",
|
||||
"bonds",
|
||||
"other",
|
||||
]);
|
||||
expect(sections[2].groups.map((group) => group.positions[0].figi)).toEqual([
|
||||
expect(sections[2].groups.map(group => group.positions[0].figi)).toEqual([
|
||||
"FIGI-FUND",
|
||||
...otherTypes.map((type) => `FIGI-${type}-NEXT`),
|
||||
...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 %");
|
||||
@@ -203,9 +215,9 @@ describe("useAccountPageViewModel", () => {
|
||||
{ state: "loaded" }
|
||||
>;
|
||||
expect(
|
||||
loaded.positionSections.map(({ kind, groups }) => ({
|
||||
loaded.positionSections.map(({ groups, kind }) => ({
|
||||
kind,
|
||||
positions: groups.flatMap((group) => group.positions),
|
||||
positions: groups.flatMap(group => group.positions),
|
||||
})),
|
||||
).toMatchObject([
|
||||
{
|
||||
@@ -213,37 +225,41 @@ describe("useAccountPageViewModel", () => {
|
||||
positions: [
|
||||
{
|
||||
displayName: "Акция",
|
||||
shareText: "66,7 %",
|
||||
logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png",
|
||||
returnText: "+10 ₽",
|
||||
returnTone: "positive",
|
||||
logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "bonds",
|
||||
positions: [
|
||||
{
|
||||
displayName: "Облигация",
|
||||
shareText: "33,3 %",
|
||||
returnText: "-5 ₽",
|
||||
returnTone: "negative",
|
||||
logoSrc: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "other",
|
||||
positions: [
|
||||
{
|
||||
displayName: "Фонд",
|
||||
shareText: null,
|
||||
returnText: null,
|
||||
returnTone: undefined,
|
||||
logoSrc: null,
|
||||
shareText: "66,7 %",
|
||||
},
|
||||
],
|
||||
},
|
||||
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
kind: "bonds",
|
||||
positions: [
|
||||
{
|
||||
displayName: "Облигация",
|
||||
logoSrc: null,
|
||||
returnText: "-5 ₽",
|
||||
returnTone: "negative",
|
||||
shareText: "33,3 %",
|
||||
},
|
||||
],
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */),
|
||||
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
kind: "other",
|
||||
positions: [
|
||||
{
|
||||
displayName: "Фонд",
|
||||
logoSrc: null,
|
||||
returnText: null,
|
||||
returnTone: undefined,
|
||||
shareText: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -265,13 +281,11 @@ describe("useAccountPageViewModel", () => {
|
||||
{ state: "loaded" }
|
||||
>;
|
||||
expect(loaded.recentOperations).toMatchObject({
|
||||
items: [{ description: "Покупка акции", id: "operation-1" }],
|
||||
state: "loaded",
|
||||
to: "/accounts/account-1/operations",
|
||||
items: [{ id: "operation-1", description: "Покупка акции" }],
|
||||
});
|
||||
expect(loaded.recentEvents).toMatchObject({
|
||||
state: "loaded",
|
||||
to: "/accounts/account-1/events",
|
||||
items: [
|
||||
{ ticker: "BND0" },
|
||||
{ ticker: "BND1" },
|
||||
@@ -279,6 +293,8 @@ describe("useAccountPageViewModel", () => {
|
||||
{ ticker: "BND3" },
|
||||
{ ticker: "BND4" },
|
||||
],
|
||||
state: "loaded",
|
||||
to: "/accounts/account-1/events",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -302,9 +318,9 @@ describe("useAccountPageViewModel", () => {
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current).toMatchObject({
|
||||
state: "loaded",
|
||||
recentOperations: { state: "error" },
|
||||
recentEvents: { state: "error" },
|
||||
recentOperations: { state: "error" },
|
||||
state: "loaded",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -320,7 +336,9 @@ it("keeps overview sections independent when their source data is incomplete", a
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json(
|
||||
resource(
|
||||
{ items: [], hasNext: false, nextCursor: null },
|
||||
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{ hasNext: false, items: [], nextCursor: null }
|
||||
/* eslint-enable unicorn/no-null */),
|
||||
{ availability: "partial" },
|
||||
),
|
||||
),
|
||||
@@ -341,20 +359,22 @@ it("keeps overview sections independent when their source data is incomplete", a
|
||||
await waitFor(() =>
|
||||
expect(result.current).toMatchObject({
|
||||
partial: true,
|
||||
recentOperations: { state: "partial", items: [] },
|
||||
recentEvents: { state: "partial", items: [] },
|
||||
recentEvents: { items: [], state: "partial" },
|
||||
recentOperations: { items: [], state: "partial" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes every overview section through one account descriptor and observational reads", async () => {
|
||||
configureTestAuth();
|
||||
const sections: Record<string, unknown> = {
|
||||
portfolio,
|
||||
operations: { items: [], hasNext: false, nextCursor: null },
|
||||
events: { events: [], incomeTotalsByCurrency: [] },
|
||||
"income-summary": { totalsByCurrency: [] },
|
||||
};
|
||||
const sections: Record<string, unknown> = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
"events": { events: [], incomeTotalsByCurrency: [] },
|
||||
"income-summary": { totalsByCurrency: [] },
|
||||
"operations": { hasNext: false, items: [], nextCursor: null },
|
||||
portfolio,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
const observed: string[] = [];
|
||||
const scopes: unknown[] = [];
|
||||
let version = "1";
|
||||
@@ -377,25 +397,27 @@ it("refreshes every overview section through one account descriptor and observat
|
||||
return HttpResponse.json(
|
||||
{
|
||||
id: "overview",
|
||||
scope: { screen: "account", accountId: "account-1" },
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
scope: { accountId: "account-1", screen: "account" },
|
||||
},
|
||||
{ status: 202 },
|
||||
);
|
||||
}),
|
||||
http.get("*/api/v1/tbank-sync/:id", () =>
|
||||
HttpResponse.json({
|
||||
id: "overview",
|
||||
scope: null,
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
state: "succeeded",
|
||||
errorCode: null,
|
||||
progress: { total: 4, completed: 4, discovering: false },
|
||||
resources: Object.keys(sections).map(
|
||||
(resourceKey) =>
|
||||
resource(null, { resourceKey, stateVersion: version }).meta,
|
||||
),
|
||||
}),
|
||||
HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
errorCode: null,
|
||||
id: "overview",
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
progress: { completed: 4, discovering: false, total: 4 },
|
||||
resources: Object.keys(sections).map(
|
||||
resourceKey =>
|
||||
resource(null, { resourceKey, stateVersion: version }).meta,
|
||||
),
|
||||
scope: null,
|
||||
state: "succeeded",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
),
|
||||
);
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||
@@ -403,10 +425,10 @@ it("refreshes every overview section through one account descriptor and observat
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current).toMatchObject({
|
||||
state: "loaded",
|
||||
recentOperations: { state: "empty" },
|
||||
recentEvents: { state: "empty" },
|
||||
incomeSummary: { state: "empty" },
|
||||
recentEvents: { state: "empty" },
|
||||
recentOperations: { state: "empty" },
|
||||
state: "loaded",
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
@@ -415,5 +437,5 @@ it("refreshes every overview section through one account descriptor and observat
|
||||
await waitFor(() =>
|
||||
expect(new Set(observed)).toEqual(new Set(Object.keys(sections))),
|
||||
);
|
||||
expect(scopes).toEqual([{ screen: "account", accountId: "account-1" }]);
|
||||
expect(scopes).toEqual([{ accountId: "account-1", screen: "account" }]);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
import { useBrokerageOperations } from "@/entities/broker-operation";
|
||||
import {
|
||||
type BrokerageAccount,
|
||||
@@ -10,7 +13,6 @@ import {
|
||||
useBrokerageEvents,
|
||||
} from "@/entities/brokerage-event";
|
||||
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { formatShare, usePortfolio } from "@/entities/portfolio";
|
||||
import { useStocks } from "@/entities/stock";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
@@ -19,15 +21,46 @@ import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
const RECENT_EVENTS_LIMIT = 5;
|
||||
|
||||
type PositionSectionKind = "stocks" | "bonds" | "other";
|
||||
type AccountPageReturnTone = "positive" | "negative";
|
||||
type AccountPageContent = {
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
incomeSummary: AccountPageIncomeSummary;
|
||||
partial?: boolean;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
refreshError: boolean;
|
||||
refreshing: boolean;
|
||||
retry: () => Promise<unknown> | undefined;
|
||||
sourceError?: boolean;
|
||||
};
|
||||
type AccountPageEmptyViewModel = AccountPageContent & {
|
||||
state: "empty";
|
||||
};
|
||||
|
||||
type AccountPageIncomeSummary
|
||||
= | {
|
||||
lastSuccessfulAt?: null | string;
|
||||
refreshError?: boolean;
|
||||
stale?: boolean;
|
||||
state: "loaded";
|
||||
summary: IncomeSummary;
|
||||
to: string;
|
||||
}
|
||||
| { retry: () => Promise<unknown> | 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] & {
|
||||
shareText: string | null;
|
||||
returnText: string | null;
|
||||
returnTone?: AccountPageReturnTone;
|
||||
logoSrc: string | null;
|
||||
instrumentTo?: string;
|
||||
logoSrc: null | string;
|
||||
returnText: null | string;
|
||||
returnTone?: AccountPageReturnTone;
|
||||
shareText: null | string;
|
||||
};
|
||||
|
||||
type AccountPagePositionGroup = {
|
||||
@@ -36,223 +69,72 @@ type AccountPagePositionGroup = {
|
||||
};
|
||||
|
||||
type AccountPagePositionSection = {
|
||||
kind: PositionSectionKind;
|
||||
groups: AccountPagePositionGroup[];
|
||||
kind: PositionSectionKind;
|
||||
};
|
||||
|
||||
type AccountPageRecentOperations =
|
||||
| {
|
||||
state: "loading" | "error" | "empty";
|
||||
to: string;
|
||||
}
|
||||
| {
|
||||
state: "loaded" | "partial";
|
||||
to: string;
|
||||
items: BrokerageOperation[];
|
||||
lastSuccessfulAt?: string | null;
|
||||
stale?: boolean;
|
||||
refreshError?: boolean;
|
||||
};
|
||||
|
||||
type AccountPageRecentEvents = BrokerageEventsPreviewState;
|
||||
|
||||
type AccountPageIncomeSummary =
|
||||
| { state: "loading" | "partial" | "empty"; to: string }
|
||||
| { state: "error"; to: string; retry: () => undefined | Promise<unknown> }
|
||||
| {
|
||||
state: "loaded";
|
||||
to: string;
|
||||
summary: IncomeSummary;
|
||||
lastSuccessfulAt?: string | null;
|
||||
stale?: boolean;
|
||||
refreshError?: boolean;
|
||||
};
|
||||
|
||||
type AccountPageContent = {
|
||||
sourceError?: boolean;
|
||||
partial?: boolean;
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
retry: () => undefined | Promise<unknown>;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
incomeSummary: AccountPageIncomeSummary;
|
||||
};
|
||||
|
||||
type AccountPageLoadedViewModel = AccountPageContent & {
|
||||
state: "loaded";
|
||||
portfolio: Portfolio;
|
||||
positionSections: AccountPagePositionSection[];
|
||||
};
|
||||
|
||||
type AccountPageEmptyViewModel = AccountPageContent & {
|
||||
state: "empty";
|
||||
};
|
||||
|
||||
type AccountPageViewModel =
|
||||
| { state: "loading" }
|
||||
| { state: "error"; code?: string; retry: () => undefined | Promise<unknown> }
|
||||
| AccountPageEmptyViewModel
|
||||
| AccountPageLoadedViewModel;
|
||||
|
||||
function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
||||
const { accountId: routeAccountId } = useParams();
|
||||
const accountId = scopeId ?? routeAccountId;
|
||||
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,
|
||||
});
|
||||
const events = useBrokerageEvents(accountId);
|
||||
const income = useIncomeSummary(accountId);
|
||||
const accounts = useBrokerageAccounts();
|
||||
const sync = useScreenSync({ screen: "account", accountId }, [
|
||||
accounts,
|
||||
portfolio,
|
||||
operations,
|
||||
events,
|
||||
income,
|
||||
]);
|
||||
const account = accounts.data?.find((item) => item.id === accountId);
|
||||
const recentOperations = toRecentOperations(accountId, operations);
|
||||
const recentEvents = toRecentEvents(accountId, events);
|
||||
const incomeSummary = toIncomeSummary(accountId, income);
|
||||
if (!portfolio.data) {
|
||||
if (portfolio.isError || portfolio.meta?.sync.state === "failed")
|
||||
return {
|
||||
state: "error" as const,
|
||||
code: apiErrorCode(portfolio.error),
|
||||
retry: sync.retry,
|
||||
};
|
||||
return { state: "loading" as const };
|
||||
type AccountPageRecentOperations
|
||||
= | {
|
||||
items: BrokerageOperation[];
|
||||
lastSuccessfulAt?: null | string;
|
||||
refreshError?: boolean;
|
||||
stale?: boolean;
|
||||
state: "loaded" | "partial";
|
||||
to: string;
|
||||
}
|
||||
const refresh = {
|
||||
partial: portfolio.meta?.availability === "partial",
|
||||
refreshing: portfolio.isFetching || sync.refreshing,
|
||||
sourceError: sync.sourceError,
|
||||
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
|
||||
retry: sync.retry,
|
||||
};
|
||||
if (
|
||||
portfolio.meta?.availability === "complete" &&
|
||||
!portfolio.data.positions.length &&
|
||||
!portfolio.data.cashBalances.length
|
||||
)
|
||||
return {
|
||||
state: "empty" as const,
|
||||
account,
|
||||
accountId,
|
||||
recentOperations,
|
||||
recentEvents,
|
||||
incomeSummary,
|
||||
...refresh,
|
||||
};
|
||||
return {
|
||||
state: "loaded" as const,
|
||||
account,
|
||||
accountId,
|
||||
portfolio: portfolio.data,
|
||||
positionSections: toAccountPagePositionSections(
|
||||
portfolio.data,
|
||||
instrumentDetails,
|
||||
),
|
||||
recentOperations,
|
||||
recentEvents,
|
||||
incomeSummary,
|
||||
...refresh,
|
||||
| {
|
||||
state: "empty" | "error" | "loading";
|
||||
to: string;
|
||||
};
|
||||
|
||||
type AccountPageReturnTone = "negative" | "positive";
|
||||
|
||||
type AccountPageViewModel
|
||||
= | AccountPageEmptyViewModel
|
||||
| AccountPageLoadedViewModel
|
||||
| { code?: string; retry: () => Promise<unknown> | undefined; state: "error" }
|
||||
| { state: "loading" };
|
||||
|
||||
type PositionSectionKind = "bonds" | "other" | "stocks";
|
||||
|
||||
function groupPositions(
|
||||
positions: AccountPagePosition[],
|
||||
): AccountPagePositionGroup[] {
|
||||
const groups = new Map<string, AccountPagePositionGroup>();
|
||||
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 toIncomeSummary(
|
||||
accountId: string | undefined,
|
||||
income: ReturnType<typeof useIncomeSummary>,
|
||||
): 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 { state: "error", to, retry: () => income.refetch() };
|
||||
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
|
||||
return {
|
||||
state: "loaded",
|
||||
to,
|
||||
summary: income.data,
|
||||
lastSuccessfulAt: income.meta?.lastSuccessfulAt,
|
||||
stale: income.meta?.freshness === "stale",
|
||||
refreshError: income.isError || income.meta?.sync.state === "failed",
|
||||
};
|
||||
}
|
||||
|
||||
function toRecentOperations(
|
||||
accountId: string | undefined,
|
||||
operations: ReturnType<typeof useBrokerageOperations>,
|
||||
): 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 { state: "partial", to, items: operations.data?.items ?? [] };
|
||||
if (
|
||||
!operations.data &&
|
||||
(operations.isError || operations.meta?.sync.state === "failed")
|
||||
)
|
||||
return { state: "error", to };
|
||||
if (!operations.data?.items.length) return { state: "empty", to };
|
||||
return {
|
||||
state: "loaded",
|
||||
to,
|
||||
items: operations.data.items,
|
||||
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
|
||||
stale: operations.meta?.freshness === "stale",
|
||||
refreshError:
|
||||
operations.isError || operations.meta?.sync.state === "failed",
|
||||
};
|
||||
}
|
||||
|
||||
function toRecentEvents(
|
||||
accountId: string | undefined,
|
||||
events: ReturnType<typeof useBrokerageEvents>,
|
||||
): 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 {
|
||||
state: "partial",
|
||||
to,
|
||||
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
|
||||
};
|
||||
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) return { state: "empty", to };
|
||||
return {
|
||||
state: "loaded",
|
||||
to,
|
||||
items,
|
||||
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
|
||||
stale: events.meta?.freshness === "stale",
|
||||
refreshError: events.isError || events.meta?.sync.state === "failed",
|
||||
};
|
||||
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<string, { slug: string } | undefined> = new Map(),
|
||||
instrumentDetails: Map<string, undefined | { slug: string }> = new Map(),
|
||||
): AccountPagePositionSection[] {
|
||||
const denominators = new Map<string, number>();
|
||||
for (const position of portfolio.positions) {
|
||||
@@ -271,104 +153,225 @@ function toAccountPagePositionSections(
|
||||
);
|
||||
const bonds = toSection("bonds", portfolio, denominators, instrumentDetails);
|
||||
const other = toSection("other", portfolio, denominators, instrumentDetails);
|
||||
return [stocks, bonds, other].filter((section) => section.groups.length > 0);
|
||||
return [stocks, bonds, other].filter(section => section.groups.length > 0);
|
||||
}
|
||||
|
||||
function toSection(
|
||||
kind: PositionSectionKind,
|
||||
portfolio: Portfolio,
|
||||
denominators: Map<string, number>,
|
||||
instrumentDetails: Map<string, { slug: string } | undefined>,
|
||||
): AccountPagePositionSection {
|
||||
function toIncomeSummary(
|
||||
accountId: string | undefined,
|
||||
income: ReturnType<typeof useIncomeSummary>,
|
||||
): 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 {
|
||||
kind,
|
||||
groups: groupPositions(
|
||||
portfolio.positions
|
||||
.filter((position) => {
|
||||
if (kind === "stocks") return position.type === "STOCK";
|
||||
if (kind === "bonds") return position.type === "BOND";
|
||||
return (
|
||||
position.type !== "STOCK" &&
|
||||
position.type !== "BOND" &&
|
||||
position.type !== "CURRENCY"
|
||||
);
|
||||
})
|
||||
.map((position) =>
|
||||
toPosition(
|
||||
position,
|
||||
denominators,
|
||||
kind,
|
||||
instrumentDetails.get(position.instrumentUid ?? "")?.slug,
|
||||
),
|
||||
),
|
||||
),
|
||||
lastSuccessfulAt: income.meta?.lastSuccessfulAt,
|
||||
refreshError: income.isError || income.meta?.sync.state === "failed",
|
||||
stale: income.meta?.freshness === "stale",
|
||||
state: "loaded",
|
||||
summary: income.data,
|
||||
to,
|
||||
};
|
||||
}
|
||||
|
||||
function groupPositions(
|
||||
positions: AccountPagePosition[],
|
||||
): AccountPagePositionGroup[] {
|
||||
const groups = new Map<string, AccountPagePositionGroup>();
|
||||
for (const [index, position] of positions.entries()) {
|
||||
const identity = position.instrumentUid?.trim()
|
||||
? ["uid", position.instrumentUid.trim()]
|
||||
: position.figi?.trim()
|
||||
? ["figi", position.figi.trim()]
|
||||
: position.isin?.trim()
|
||||
? ["isin", position.isin.trim()]
|
||||
: null;
|
||||
const rowKey = JSON.stringify(["row", position.account?.id, index]);
|
||||
const key =
|
||||
position.account && identity
|
||||
? JSON.stringify([position.type, ...identity])
|
||||
: rowKey;
|
||||
const group = groups.get(key);
|
||||
if (
|
||||
group &&
|
||||
!group.positions.some((item) => item.account?.id === position.account?.id)
|
||||
) {
|
||||
group.positions.push(position);
|
||||
} else {
|
||||
const uniqueKey = group ? rowKey : key;
|
||||
groups.set(uniqueKey, { key: uniqueKey, positions: [position] });
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
function toPosition(
|
||||
position: Portfolio["positions"][number],
|
||||
denominators: Map<string, number>,
|
||||
kind: PositionSectionKind,
|
||||
instrumentSlug: string | undefined,
|
||||
): AccountPagePosition {
|
||||
const expanded = kind !== "other";
|
||||
const isExpanded = kind !== "other";
|
||||
const returnValue = position.absoluteReturn
|
||||
? Number(position.absoluteReturn.value)
|
||||
: null;
|
||||
const returnTone =
|
||||
returnValue === null || returnValue === 0
|
||||
? undefined
|
||||
: returnValue > 0
|
||||
? "positive"
|
||||
: "negative";
|
||||
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ 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) : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
|
||||
returnText: position.absoluteReturn
|
||||
? `${returnPrefix}${formatMoney(position.absoluteReturn)}`
|
||||
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
|
||||
returnTone,
|
||||
shareText: position.currentValue?.currency
|
||||
? formatShare(position.currentValue, {
|
||||
currency: position.currentValue.currency,
|
||||
value: String(denominators.get(position.currentValue.currency) ?? 0),
|
||||
})
|
||||
: null,
|
||||
returnText: position.absoluteReturn
|
||||
? `${expanded && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
|
||||
: null,
|
||||
returnTone,
|
||||
logoSrc: expanded ? instrumentLogoUrl(position.logoName) : null,
|
||||
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
|
||||
};
|
||||
}
|
||||
|
||||
function toRecentEvents(
|
||||
accountId: string | undefined,
|
||||
events: ReturnType<typeof useBrokerageEvents>,
|
||||
): 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<typeof useBrokerageOperations>,
|
||||
): 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<string, number>,
|
||||
instrumentDetails: Map<string, undefined | { slug: string }>,
|
||||
): 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;
|
||||
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,
|
||||
});
|
||||
const events = useBrokerageEvents(accountId);
|
||||
const income = useIncomeSummary(accountId);
|
||||
const accounts = useBrokerageAccounts();
|
||||
const sync = useScreenSync({ accountId, screen: "account" }, [
|
||||
accounts,
|
||||
portfolio,
|
||||
operations,
|
||||
events,
|
||||
income,
|
||||
]);
|
||||
|
||||
if (!portfolio.data) {
|
||||
if (portfolio.isError || portfolio.meta?.sync.state === "failed")
|
||||
return {
|
||||
code: apiErrorCode(portfolio.error),
|
||||
retry: sync.retry,
|
||||
state: "error" as const,
|
||||
};
|
||||
return { state: "loading" as const };
|
||||
}
|
||||
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 refresh = {
|
||||
partial: portfolio.meta?.availability === "partial",
|
||||
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
|
||||
refreshing: portfolio.isFetching || sync.refreshing,
|
||||
retry: sync.retry,
|
||||
sourceError: sync.sourceError,
|
||||
};
|
||||
if (
|
||||
portfolio.meta?.availability === "complete"
|
||||
&& portfolio.data.positions.length === 0
|
||||
&& portfolio.data.cashBalances.length === 0
|
||||
)
|
||||
return {
|
||||
account,
|
||||
accountId,
|
||||
incomeSummary,
|
||||
recentEvents,
|
||||
recentOperations,
|
||||
state: "empty" as const,
|
||||
...refresh,
|
||||
};
|
||||
return {
|
||||
account,
|
||||
accountId,
|
||||
incomeSummary,
|
||||
portfolio: portfolio.data,
|
||||
positionSections: toAccountPagePositionSections(
|
||||
portfolio.data,
|
||||
instrumentDetails,
|
||||
),
|
||||
recentEvents,
|
||||
recentOperations,
|
||||
state: "loaded" as const,
|
||||
...refresh,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Stack, Text, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||
|
||||
import { accountTypeKey } from "@/entities/brokerage-account";
|
||||
|
||||
type AccountHeadingProps = {
|
||||
@@ -13,6 +16,10 @@ const AccountHeading: FC<AccountHeadingProps> = (props) => {
|
||||
const { account } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
let description: string | undefined;
|
||||
if (props.accountId === "all") description = t("accounts.allDescription");
|
||||
else if (account) description = t(accountTypeKey(account.type));
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Title order={1}>
|
||||
@@ -20,15 +27,7 @@ const AccountHeading: FC<AccountHeadingProps> = (props) => {
|
||||
? t("accounts.all")
|
||||
: (account?.name ?? t("portfolio.title"))}
|
||||
</Title>
|
||||
{props.accountId === "all" ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("accounts.allDescription")}
|
||||
</Text>
|
||||
) : account ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(accountTypeKey(account.type))}
|
||||
</Text>
|
||||
) : null}
|
||||
{description && <Text c="dimmed" size="sm">{description}</Text>}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Stack } from "@mantine/core";
|
||||
import { StateView } from "@wealthledger/ui";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||
|
||||
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||
|
||||
import type {
|
||||
AccountPageIncomeSummary,
|
||||
AccountPageRecentEvents,
|
||||
AccountPageRecentOperations,
|
||||
} from "../AccountPage.vm";
|
||||
|
||||
import { AccountHeading } from "./AccountHeading.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
import { PortfolioRefreshState } from "./PortfolioRefreshState/PortfolioRefreshState.view";
|
||||
@@ -18,13 +23,13 @@ import { RecentOperations } from "./RecentOperations.view";
|
||||
type AccountPageEmptyProps = {
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
refreshing?: boolean;
|
||||
sourceError?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
incomeSummary?: AccountPageIncomeSummary;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
refreshError?: boolean;
|
||||
refreshing?: boolean;
|
||||
retry?: () => void;
|
||||
sourceError?: boolean;
|
||||
};
|
||||
|
||||
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||
@@ -33,25 +38,25 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||
<Stack>
|
||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||
<PortfolioRefreshState
|
||||
refreshing={props.refreshing ?? false}
|
||||
sourceError={props.sourceError}
|
||||
refreshError={props.refreshError ?? false}
|
||||
refreshing={props.refreshing ?? false}
|
||||
retry={props.retry}
|
||||
sourceError={props.sourceError}
|
||||
/>
|
||||
<StateView
|
||||
title={t("portfolio.emptyTitle")}
|
||||
text={t(
|
||||
props.accountId === "all"
|
||||
? "portfolio.allEmptyText"
|
||||
: "portfolio.emptyText",
|
||||
)}
|
||||
title={t("portfolio.emptyTitle")}
|
||||
/>
|
||||
<PortfolioStructure
|
||||
structure={{ currencies: [], unvaluedCategories: [] }}
|
||||
/>
|
||||
<RecentOperations operations={props.recentOperations} />
|
||||
<BrokerageEventsPreview events={props.recentEvents} />
|
||||
{props.incomeSummary ? <IncomeSummary {...props.incomeSummary} /> : null}
|
||||
{!!(props.incomeSummary) && (<IncomeSummary {...props.incomeSummary} />)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Button } from "@mantine/core";
|
||||
import { StateView } from "@wealthledger/ui";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
@@ -12,44 +13,44 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
|
||||
if (code === "NOT_FOUND")
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("portfolio.accountUnavailableTitle")}
|
||||
text={t("portfolio.accountUnavailableText")}
|
||||
action={
|
||||
action={(
|
||||
<Button component={Link} to="/accounts">
|
||||
{t("portfolio.back")}
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
error
|
||||
text={t("portfolio.accountUnavailableText")}
|
||||
title={t("portfolio.accountUnavailableTitle")}
|
||||
/>
|
||||
);
|
||||
if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("portfolio.connectionErrorTitle")}
|
||||
text={t("portfolio.connectionErrorText")}
|
||||
action={
|
||||
action={(
|
||||
<Button component={Link} to="/settings">
|
||||
{t("accounts.openSettings")}
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
error
|
||||
text={t("portfolio.connectionErrorText")}
|
||||
title={t("portfolio.connectionErrorTitle")}
|
||||
/>
|
||||
);
|
||||
if (code === "TBANK_UNAVAILABLE")
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("accounts.unavailableTitle")}
|
||||
text={t("accounts.unavailableText")}
|
||||
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
|
||||
error
|
||||
text={t("accounts.unavailableText")}
|
||||
title={t("accounts.unavailableTitle")}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("states.errorTitle")}
|
||||
text={t("states.errorText")}
|
||||
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
|
||||
error
|
||||
text={t("states.errorText")}
|
||||
title={t("states.errorTitle")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { Accordion, Alert, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Accordion, Alert, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import classes from "../AccountPage.module.css";
|
||||
|
||||
import type {
|
||||
AccountPageIncomeSummary,
|
||||
AccountPagePositionSection,
|
||||
AccountPageRecentEvents,
|
||||
AccountPageRecentOperations,
|
||||
} from "../AccountPage.vm";
|
||||
|
||||
import classes from "../AccountPage.module.css";
|
||||
import { AccountHeading } from "./AccountHeading.view";
|
||||
import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
@@ -23,16 +28,16 @@ import { RecentOperations } from "./RecentOperations.view";
|
||||
type AccountPageLoadedProps = {
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
incomeSummary?: AccountPageIncomeSummary;
|
||||
partial?: boolean;
|
||||
portfolio: Portfolio;
|
||||
positionSections: AccountPagePositionSection[];
|
||||
refreshing?: boolean;
|
||||
sourceError?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
incomeSummary?: AccountPageIncomeSummary;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
refreshError?: boolean;
|
||||
refreshing?: boolean;
|
||||
retry?: () => void;
|
||||
sourceError?: boolean;
|
||||
};
|
||||
|
||||
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
@@ -41,14 +46,14 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
<Stack gap="lg">
|
||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||
<PortfolioRefreshState
|
||||
refreshing={props.refreshing ?? false}
|
||||
sourceError={props.sourceError}
|
||||
refreshError={props.refreshError ?? false}
|
||||
refreshing={props.refreshing ?? false}
|
||||
retry={props.retry}
|
||||
sourceError={props.sourceError}
|
||||
/>
|
||||
{props.portfolio.isStale ? (
|
||||
{!!(props.portfolio.isStale) && ((
|
||||
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
|
||||
) : null}
|
||||
))}
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(
|
||||
props.accountId === "all"
|
||||
@@ -59,34 +64,36 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
{props.partial ? (
|
||||
<Alert role="status">{t("investment.partial")}</Alert>
|
||||
) : (
|
||||
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
|
||||
)}
|
||||
{!props.partial ? (
|
||||
{props.partial
|
||||
? (
|
||||
<Alert role="status">{t("investment.partial")}</Alert>
|
||||
)
|
||||
: (
|
||||
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
|
||||
)}
|
||||
{!(props.partial) && ((
|
||||
<PortfolioStructure
|
||||
key={props.accountId}
|
||||
structure={props.portfolio.structure}
|
||||
/>
|
||||
) : null}
|
||||
))}
|
||||
<Accordion
|
||||
multiple
|
||||
classNames={{
|
||||
chevron: classes.accordionChevron,
|
||||
control: classes.accordionControl,
|
||||
label: classes.accordionLabel,
|
||||
panel: classes.accordionPanel,
|
||||
}}
|
||||
defaultValue={[
|
||||
...props.positionSections.map((section) => section.kind),
|
||||
...props.positionSections.map(section => section.kind),
|
||||
"cash",
|
||||
"recentOperations",
|
||||
"recentEvents",
|
||||
"incomeSummary",
|
||||
]}
|
||||
classNames={{
|
||||
control: classes.accordionControl,
|
||||
label: classes.accordionLabel,
|
||||
chevron: classes.accordionChevron,
|
||||
panel: classes.accordionPanel,
|
||||
}}
|
||||
multiple
|
||||
>
|
||||
{props.positionSections.map((section) => (
|
||||
{props.positionSections.map(section => (
|
||||
<Accordion.Item key={section.kind} value={section.kind}>
|
||||
<Accordion.Control>
|
||||
{t(positionSectionTitleKey(section.kind))}
|
||||
@@ -117,9 +124,9 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
<Accordion.Item value="incomeSummary">
|
||||
<Accordion.Control>{t("income.summaryTitle")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{props.incomeSummary ? (
|
||||
{!!(props.incomeSummary) && ((
|
||||
<IncomeSummary {...props.incomeSummary} />
|
||||
) : null}
|
||||
))}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
@@ -129,8 +136,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
|
||||
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
|
||||
if (kind === "stocks") return "portfolio.stocks";
|
||||
if (kind === "bonds") return "portfolio.bonds";
|
||||
return "portfolio.otherPositions";
|
||||
return kind === "bonds" ? "portfolio.bonds" : "portfolio.otherPositions";
|
||||
}
|
||||
|
||||
export { AccountPageLoaded };
|
||||
|
||||
+3
-2
@@ -1,10 +1,9 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
|
||||
import { AccountPageLoading } from "./AccountPageLoading.view";
|
||||
|
||||
const meta = {
|
||||
title: "Product/AccountPageLoading",
|
||||
component: AccountPageLoading,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
@@ -13,6 +12,8 @@ const meta = {
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
title: "Product/AccountPageLoading",
|
||||
} satisfies Meta<typeof AccountPageLoading>;
|
||||
|
||||
export default meta;
|
||||
|
||||
+13
-11
@@ -1,7 +1,9 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Paper, SimpleGrid, Skeleton } from "@mantine/core";
|
||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import classes from "../../AccountPage.module.css";
|
||||
|
||||
const AccountPageLoading: FC = () => {
|
||||
@@ -10,25 +12,25 @@ const AccountPageLoading: FC = () => {
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Skeleton height={32} width="35%" />
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, sm: 2, md: 3 }}
|
||||
spacing={10}
|
||||
aria-hidden="true"
|
||||
cols={{ base: 1, md: 3, sm: 2 }}
|
||||
spacing={10}
|
||||
>
|
||||
{[0, 1, 2].map((index) => (
|
||||
<Paper key={index} className={classes.total} withBorder>
|
||||
<Skeleton height={24} width={40} radius={4} />
|
||||
{["first", "second", "third"].map(placeholder => (
|
||||
<Paper className={classes.total} key={placeholder} withBorder>
|
||||
<Skeleton height={24} radius={4} width={40} />
|
||||
<Skeleton
|
||||
height={21}
|
||||
width={180}
|
||||
radius={4}
|
||||
className={classes.totalAmountSkeleton}
|
||||
height={21}
|
||||
radius={4}
|
||||
width={180}
|
||||
/>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Skeleton height={260} radius="sm" />
|
||||
{[0, 1, 2].map((index) => (
|
||||
<Skeleton key={index} height={52} radius="sm" />
|
||||
{["first", "second", "third"].map(placeholder => (
|
||||
<Skeleton height={52} key={placeholder} radius="sm" />
|
||||
))}
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { CurrencyCards } from "./CurrencyCards.view";
|
||||
|
||||
describe("CurrencyCards currency presentation", () => {
|
||||
@@ -20,12 +21,14 @@ describe("CurrencyCards currency presentation", () => {
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.queryAllByRole("definition").map((node) => node.textContent),
|
||||
screen.queryAllByRole("definition").map(node => node.textContent),
|
||||
).toEqual(
|
||||
["3,00 €", "2,00 $", "1,00 ₽", "4,00 CAD", "5,00 XYZ"].slice(0, count),
|
||||
);
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||
expect(container.querySelectorAll("dl")).toHaveLength(count);
|
||||
expect(
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||
container.querySelector("[tabindex], button, a, input"),
|
||||
).toBeNull();
|
||||
},
|
||||
@@ -57,11 +60,14 @@ describe("CurrencyCards currency presentation", () => {
|
||||
);
|
||||
const term = screen.getByRole("term");
|
||||
expect(term).toHaveTextContent(name);
|
||||
expect(term.querySelector('[aria-hidden="true"]')).toHaveTextContent(
|
||||
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||
expect(term.querySelector("[aria-hidden=\"true\"]")).toHaveTextContent(
|
||||
symbol,
|
||||
);
|
||||
expect(screen.getByRole("definition")).toHaveTextContent(expected);
|
||||
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||
expect(term.parentElement).toBe(
|
||||
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||
screen.getByRole("definition").parentElement,
|
||||
);
|
||||
},
|
||||
@@ -79,7 +85,7 @@ describe("CurrencyCards currency presentation", () => {
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.getAllByRole("definition").map((node) => node.textContent),
|
||||
screen.getAllByRole("definition").map(node => node.textContent),
|
||||
).toEqual(["1,00 $", "2,00 CAD"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Paper, SimpleGrid, Text, VisuallyHidden } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Paper, SimpleGrid, Text, VisuallyHidden } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
import { formatDecimal } from "@/shared/lib";
|
||||
|
||||
import classes from "../../AccountPage.module.css";
|
||||
|
||||
const SIGN_PREFIX_LENGTH = 2;
|
||||
|
||||
type CurrencyCardsProps = {
|
||||
amounts: Portfolio["totalsByCurrency"];
|
||||
};
|
||||
@@ -12,25 +18,25 @@ type CurrencyCardsProps = {
|
||||
const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
|
||||
const { amounts } = props;
|
||||
const { t } = useTranslation();
|
||||
const currencies: Record<string, { symbol: string; name: string }> = {
|
||||
RUB: { symbol: "₽", name: t("portfolio.currencies.RUB") },
|
||||
USD: { symbol: "$", name: t("portfolio.currencies.USD") },
|
||||
EUR: { symbol: "€", name: t("portfolio.currencies.EUR") },
|
||||
const currencies: Record<string, { name: string; symbol: string }> = {
|
||||
EUR: { name: t("portfolio.currencies.EUR"), symbol: "€" },
|
||||
RUB: { name: t("portfolio.currencies.RUB"), symbol: "₽" },
|
||||
USD: { name: t("portfolio.currencies.USD"), symbol: "$" },
|
||||
};
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing={10}>
|
||||
<SimpleGrid cols={{ base: 1, md: 3, sm: 2 }} spacing={10}>
|
||||
{amounts.map((money) => {
|
||||
const amount = formatDecimal(money.value);
|
||||
return (
|
||||
<Paper
|
||||
component="dl"
|
||||
m={0}
|
||||
className={classes.total}
|
||||
component="dl"
|
||||
key={money.currency}
|
||||
m={0}
|
||||
withBorder
|
||||
>
|
||||
<Text component="dt" className={classes.totalBadge}>
|
||||
<Text className={classes.totalBadge} component="dt">
|
||||
<span aria-hidden="true">
|
||||
{currencies[money.currency]?.symbol ?? money.currency}
|
||||
</span>
|
||||
@@ -38,17 +44,20 @@ const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
|
||||
{currencies[money.currency]?.name ?? money.currency}
|
||||
</VisuallyHidden>
|
||||
</Text>
|
||||
<Text component="dd" m={0} className={classes.totalAmount}>
|
||||
{amount.startsWith("-") ? (
|
||||
<>
|
||||
<span className={classes.totalLeadingDigits}>
|
||||
{amount.slice(0, 2)}
|
||||
</span>
|
||||
{amount.slice(2)}
|
||||
</>
|
||||
) : (
|
||||
amount
|
||||
)}{" "}
|
||||
<Text className={classes.totalAmount} component="dd" m={0}>
|
||||
{amount.startsWith("-")
|
||||
? (
|
||||
<>
|
||||
<span className={classes.totalLeadingDigits}>
|
||||
{amount.slice(0, SIGN_PREFIX_LENGTH)}
|
||||
</span>
|
||||
{amount.slice(SIGN_PREFIX_LENGTH)}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
amount
|
||||
)}
|
||||
{" "}
|
||||
{currencies[money.currency]?.symbol ?? money.currency}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Button, Paper, Stack, Text } from "@mantine/core";
|
||||
import { StateView } from "@wealthledger/ui";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
|
||||
|
||||
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
|
||||
type IncomeSummaryProps =
|
||||
| { state: "loading"; to: string }
|
||||
| { state: "partial"; to: string }
|
||||
| { state: "error"; to: string; retry: () => void }
|
||||
type IncomeSummaryProps
|
||||
= | {
|
||||
lastSuccessfulAt?: null | string;
|
||||
refreshError?: boolean;
|
||||
stale?: boolean;
|
||||
state: "loaded";
|
||||
summary: IncomeSummaryData;
|
||||
to: string;
|
||||
}
|
||||
| { retry: () => void; state: "error"; to: string }
|
||||
| { state: "empty"; to: string }
|
||||
| {
|
||||
state: "loaded";
|
||||
to: string;
|
||||
summary: IncomeSummaryData;
|
||||
lastSuccessfulAt?: string | null;
|
||||
stale?: boolean;
|
||||
refreshError?: boolean;
|
||||
};
|
||||
| { state: "loading"; to: string }
|
||||
| { state: "partial"; to: string };
|
||||
|
||||
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
if (props.state === "loading" || props.state === "partial")
|
||||
return (
|
||||
<Text role="status" c="dimmed">
|
||||
<Text c="dimmed" role="status">
|
||||
{t(
|
||||
props.state === "partial"
|
||||
? "investment.partial"
|
||||
@@ -35,12 +38,12 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||
if (props.state === "error")
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("income.summaryError")}
|
||||
text={t("income.errorText")}
|
||||
action={
|
||||
<Button onClick={props.retry}>{t("income.summaryRetry")}</Button>
|
||||
}
|
||||
error
|
||||
text={t("income.errorText")}
|
||||
title={t("income.summaryError")}
|
||||
/>
|
||||
);
|
||||
if (props.state === "empty")
|
||||
@@ -54,23 +57,32 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||
);
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{props.lastSuccessfulAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{!!(props.lastSuccessfulAt) && ((
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{props.stale ? <Text size="xs">{t("investment.stale")}</Text> : null}
|
||||
{props.refreshError ? (
|
||||
))}
|
||||
{!!(props.stale) && (<Text size="xs">{t("investment.stale")}</Text>)}
|
||||
{!!(props.refreshError) && ((
|
||||
<Text role="status">{t("income.refreshError")}</Text>
|
||||
) : null}
|
||||
{props.summary.totalsByCurrency.map((total) => (
|
||||
))}
|
||||
{props.summary.totalsByCurrency.map(total => (
|
||||
<Paper key={total.currency} p="sm" withBorder>
|
||||
<Text fw={700}>{formatMoney(total.total)}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
|
||||
{t("income.dividend")}: {formatMoney(total.dividend)}
|
||||
{t("income.coupon")}
|
||||
:
|
||||
{" "}
|
||||
{formatMoney(total.coupon)}
|
||||
{" "}
|
||||
·
|
||||
{" "}
|
||||
{t("income.dividend")}
|
||||
:
|
||||
{" "}
|
||||
{formatMoney(total.dividend)}
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
+13
-11
@@ -1,19 +1,19 @@
|
||||
import { Stack } from "@mantine/core";
|
||||
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 = {
|
||||
title: "Product/PortfolioRefreshState",
|
||||
component: PortfolioRefreshState,
|
||||
tags: ["autodocs"],
|
||||
args: { refreshing: false, refreshError: false, retry: fn() },
|
||||
args: { refreshError: false, refreshing: false, retry: fn() },
|
||||
argTypes: {
|
||||
refreshing: { control: "boolean" },
|
||||
refreshError: { control: "boolean" },
|
||||
refreshing: { control: "boolean" },
|
||||
retry: { control: false },
|
||||
},
|
||||
component: PortfolioRefreshState,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
@@ -22,7 +22,7 @@ const meta = {
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (args) => (
|
||||
render: arguments_ => (
|
||||
<Stack>
|
||||
<CurrencyCards
|
||||
amounts={[
|
||||
@@ -30,9 +30,11 @@ const meta = {
|
||||
{ currency: "USD", value: "320" },
|
||||
]}
|
||||
/>
|
||||
<PortfolioRefreshState {...args} />
|
||||
<PortfolioRefreshState {...arguments_} />
|
||||
</Stack>
|
||||
),
|
||||
tags: ["autodocs"],
|
||||
title: "Product/PortfolioRefreshState",
|
||||
} satisfies Meta<typeof PortfolioRefreshState>;
|
||||
|
||||
export default meta;
|
||||
@@ -40,15 +42,15 @@ export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Idle: Story = { name: "Сохранённые данные" };
|
||||
export const Refreshing: Story = {
|
||||
name: "Фоновое обновление",
|
||||
args: { refreshing: true },
|
||||
name: "Фоновое обновление",
|
||||
};
|
||||
export const RefreshError: Story = {
|
||||
name: "Ошибка обновления с retry",
|
||||
args: { refreshError: true },
|
||||
name: "Ошибка обновления с retry",
|
||||
};
|
||||
|
||||
export const SourceFailure: Story = {
|
||||
name: "Ошибка синхронизации сохраняет суммы",
|
||||
args: { refreshError: true, sourceError: true },
|
||||
name: "Ошибка синхронизации сохраняет суммы",
|
||||
};
|
||||
|
||||
+12
-11
@@ -1,38 +1,39 @@
|
||||
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type PortfolioRefreshStateProps = {
|
||||
sourceError?: boolean;
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
refreshing: boolean;
|
||||
retry?: () => void;
|
||||
sourceError?: boolean;
|
||||
};
|
||||
|
||||
const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
|
||||
const { refreshing, refreshError, retry } = props;
|
||||
const { refreshError, refreshing, retry } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={retry}
|
||||
disabled={refreshing}
|
||||
onClick={retry}
|
||||
variant="light"
|
||||
w="fit-content"
|
||||
>
|
||||
{t(refreshError ? "accounts.retry" : "investment.refresh")}
|
||||
</Button>
|
||||
{refreshing ? (
|
||||
<Group gap="xs" role="status" aria-label={t("portfolio.refreshing")}>
|
||||
{!!(refreshing) && ((
|
||||
<Group aria-label={t("portfolio.refreshing")} gap="xs" role="status">
|
||||
<Loader size="sm" />
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("portfolio.refreshing")}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
{refreshError ? (
|
||||
<Alert color="orange" role="status" aria-live="polite">
|
||||
))}
|
||||
{!!(refreshError) && ((
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
<Stack gap="sm">
|
||||
<Text>
|
||||
{t(
|
||||
@@ -43,7 +44,7 @@ const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+34
-28
@@ -1,23 +1,23 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
import { PortfolioStructure } from "./PortfolioStructure";
|
||||
|
||||
const rub: Portfolio["structure"]["currencies"][number] = {
|
||||
currency: "RUB",
|
||||
totalValue: "1250000",
|
||||
hasNegativeValues: false,
|
||||
categories: [
|
||||
{ type: "STOCK", value: "650000", sharePercent: "52" },
|
||||
{ type: "BOND", value: "375000", sharePercent: "30" },
|
||||
{ type: "OTHER", value: "125000", sharePercent: "10" },
|
||||
{ type: "CASH", value: "100000", sharePercent: "8" },
|
||||
{ sharePercent: "52", type: "STOCK", value: "650000" },
|
||||
{ sharePercent: "30", type: "BOND", value: "375000" },
|
||||
{ sharePercent: "10", type: "OTHER", value: "125000" },
|
||||
{ sharePercent: "8", type: "CASH", value: "100000" },
|
||||
],
|
||||
currency: "RUB",
|
||||
hasNegativeValues: false,
|
||||
totalValue: "1250000",
|
||||
};
|
||||
const meta = {
|
||||
title: "Product/PortfolioStructure",
|
||||
component: PortfolioStructure,
|
||||
tags: ["autodocs"],
|
||||
args: { structure: { currencies: [rub], unvaluedCategories: [] } },
|
||||
component: PortfolioStructure,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
@@ -26,6 +26,8 @@ const meta = {
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
title: "Product/PortfolioStructure",
|
||||
} satisfies Meta<typeof PortfolioStructure>;
|
||||
|
||||
export default meta;
|
||||
@@ -37,10 +39,10 @@ export const MultipleCurrencies: Story = {
|
||||
currencies: [
|
||||
rub,
|
||||
{
|
||||
categories: [{ sharePercent: "100", type: "CASH", value: "500" }],
|
||||
currency: "USD",
|
||||
totalValue: "500",
|
||||
hasNegativeValues: false,
|
||||
categories: [{ type: "CASH", value: "500", sharePercent: "100" }],
|
||||
totalValue: "500",
|
||||
},
|
||||
],
|
||||
unvaluedCategories: [],
|
||||
@@ -52,14 +54,14 @@ export const CashOnly: Story = {
|
||||
structure: {
|
||||
currencies: [
|
||||
{
|
||||
currency: "RUB",
|
||||
totalValue: "100000",
|
||||
hasNegativeValues: false,
|
||||
categories: rub.categories.map((row) => ({
|
||||
categories: rub.categories.map(row => ({
|
||||
...row,
|
||||
value: row.type === "CASH" ? "100000" : "0",
|
||||
sharePercent: row.type === "CASH" ? "100" : "0",
|
||||
value: row.type === "CASH" ? "100000" : "0",
|
||||
})),
|
||||
currency: "RUB",
|
||||
hasNegativeValues: false,
|
||||
totalValue: "100000",
|
||||
},
|
||||
],
|
||||
unvaluedCategories: [],
|
||||
@@ -75,13 +77,15 @@ export const Incomplete: Story = {
|
||||
currencies: [
|
||||
{
|
||||
...rub,
|
||||
categories: rub.categories.map((row) => ({
|
||||
...row,
|
||||
sharePercent: null,
|
||||
})),
|
||||
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
...row,
|
||||
sharePercent: null,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */))),
|
||||
},
|
||||
],
|
||||
unvaluedCategories: [{ type: "STOCK", count: 2 }],
|
||||
unvaluedCategories: [{ count: 2, type: "STOCK" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -89,7 +93,7 @@ export const UnknownOnly: Story = {
|
||||
args: {
|
||||
structure: {
|
||||
currencies: [],
|
||||
unvaluedCategories: [{ type: "BOND", count: 1 }],
|
||||
unvaluedCategories: [{ count: 1, type: "BOND" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -99,13 +103,15 @@ export const Negative: Story = {
|
||||
currencies: [
|
||||
{
|
||||
...rub,
|
||||
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
...row,
|
||||
sharePercent: null,
|
||||
value: row.type === "OTHER" ? "25000" : row.value,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */))),
|
||||
hasNegativeValues: true,
|
||||
totalValue: "1150000",
|
||||
categories: rub.categories.map((row) => ({
|
||||
...row,
|
||||
value: row.type === "OTHER" ? "25000" : row.value,
|
||||
sharePercent: null,
|
||||
})),
|
||||
},
|
||||
],
|
||||
unvaluedCategories: [],
|
||||
|
||||
+28
-22
@@ -1,28 +1,30 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
import { PortfolioStructure } from "./PortfolioStructure";
|
||||
|
||||
const rub: Portfolio["structure"]["currencies"][number] = {
|
||||
currency: "RUB",
|
||||
totalValue: "100",
|
||||
hasNegativeValues: false,
|
||||
categories: [
|
||||
{ type: "STOCK", value: "60", sharePercent: "60" },
|
||||
{ type: "BOND", value: "20", sharePercent: "20" },
|
||||
{ type: "OTHER", value: "0", sharePercent: "0" },
|
||||
{ type: "CASH", value: "20", sharePercent: "20" },
|
||||
{ sharePercent: "60", type: "STOCK", value: "60" },
|
||||
{ sharePercent: "20", type: "BOND", value: "20" },
|
||||
{ sharePercent: "0", type: "OTHER", value: "0" },
|
||||
{ sharePercent: "20", type: "CASH", value: "20" },
|
||||
],
|
||||
currency: "RUB",
|
||||
hasNegativeValues: false,
|
||||
totalValue: "100",
|
||||
};
|
||||
const ui = (structure: Portfolio["structure"] | null | undefined) => (
|
||||
const ui = (structure: null | Portfolio["structure"] | undefined) => (
|
||||
<MantineProvider>
|
||||
<PortfolioStructure structure={structure} />
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
describe("portfolio structure", () => {
|
||||
it.each([undefined, null])(
|
||||
it.each([undefined, /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null])(
|
||||
"keeps an older snapshot without structure available (%s) and recovers on refresh",
|
||||
(structure) => {
|
||||
const view = render(ui(structure));
|
||||
@@ -66,15 +68,17 @@ describe("portfolio structure", () => {
|
||||
currencies: [
|
||||
{
|
||||
...rub,
|
||||
categories: rub.categories.map((row) => ({
|
||||
...row,
|
||||
sharePercent: null,
|
||||
})),
|
||||
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
...row,
|
||||
sharePercent: null,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */))),
|
||||
},
|
||||
],
|
||||
unvaluedCategories: [
|
||||
{ type: "OTHER", count: 2 },
|
||||
{ type: "STOCK", count: 1 },
|
||||
{ count: 2, type: "OTHER" },
|
||||
{ count: 1, type: "STOCK" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -93,12 +97,14 @@ describe("portfolio structure", () => {
|
||||
currencies: [
|
||||
{
|
||||
...rub,
|
||||
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
...row,
|
||||
sharePercent: null,
|
||||
value: row.type === "STOCK" ? "-60" : row.value,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */))),
|
||||
hasNegativeValues: true,
|
||||
categories: rub.categories.map((row) => ({
|
||||
...row,
|
||||
value: row.type === "STOCK" ? "-60" : row.value,
|
||||
sharePercent: null,
|
||||
})),
|
||||
},
|
||||
],
|
||||
unvaluedCategories: [],
|
||||
@@ -115,7 +121,7 @@ describe("portfolio structure", () => {
|
||||
});
|
||||
it("handles unknown-only and empty compositions", () => {
|
||||
const view = render(
|
||||
ui({ currencies: [], unvaluedCategories: [{ type: "BOND", count: 1 }] }),
|
||||
ui({ currencies: [], unvaluedCategories: [{ count: 1, type: "BOND" }] }),
|
||||
);
|
||||
expect(screen.getByText("Облигации: без оценки — 1")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("list")).not.toBeInTheDocument();
|
||||
@@ -130,9 +136,9 @@ describe("portfolio structure", () => {
|
||||
...rub,
|
||||
categories: [
|
||||
{
|
||||
sharePercent: "0.000001",
|
||||
type: "CASH",
|
||||
value: "9007199254740993.000000001",
|
||||
sharePercent: "0.000001",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+33
-33
@@ -1,72 +1,72 @@
|
||||
import { type FC, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
import { PortfolioStructureView } from "./PortfolioStructure.view";
|
||||
|
||||
const MIN_VISIBLE_SHARE_PERCENT = 0.01;
|
||||
|
||||
// A cached snapshot or an older running server can predate this response field.
|
||||
type PortfolioStructureProps = { structure?: Portfolio["structure"] | null };
|
||||
type PortfolioStructureProps = { structure?: null | Portfolio["structure"] };
|
||||
|
||||
const categoryPresentation = {
|
||||
STOCK: { label: "portfolio.stocks", color: "blue.6" },
|
||||
BOND: { label: "portfolio.bonds", color: "grape.6" },
|
||||
OTHER: { label: "portfolio.structure.other", color: "orange.6" },
|
||||
CASH: { label: "portfolio.structure.cash", color: "green.6" },
|
||||
BOND: { color: "grape.6", label: "portfolio.bonds" },
|
||||
CASH: { color: "green.6", label: "portfolio.structure.cash" },
|
||||
OTHER: { color: "orange.6", label: "portfolio.structure.other" },
|
||||
STOCK: { color: "blue.6", label: "portfolio.stocks" },
|
||||
} as const;
|
||||
|
||||
const PortfolioStructure: FC<PortfolioStructureProps> = (props) => {
|
||||
const { structure } = props;
|
||||
const { t } = useTranslation();
|
||||
const [currency, setCurrency] = useState<string | null>(null);
|
||||
const selected =
|
||||
structure?.currencies.find((item) => item.currency === currency) ??
|
||||
structure?.currencies[0];
|
||||
const incomplete = (structure?.unvaluedCategories.length ?? 0) > 0;
|
||||
const [currency, setCurrency] = useState<null | string>(null);
|
||||
const selected
|
||||
= structure?.currencies.find(item => item.currency === currency)
|
||||
?? structure?.currencies[0];
|
||||
const isIncomplete = (structure?.unvaluedCategories.length ?? 0) > 0;
|
||||
const rows = selected
|
||||
? selected.categories.map((category) => {
|
||||
const presentation = categoryPresentation[category.type];
|
||||
const percentage =
|
||||
category.sharePercent === null ? null : Number(category.sharePercent);
|
||||
const percentage
|
||||
= category.sharePercent === null ? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null : Number(category.sharePercent);
|
||||
const unknown = structure?.unvaluedCategories.some(
|
||||
(item) => item.type === category.type,
|
||||
item => item.type === category.type,
|
||||
);
|
||||
const amount = formatMoney({
|
||||
currency: selected.currency,
|
||||
value: category.value,
|
||||
});
|
||||
let displayedAmount = amount;
|
||||
if (unknown) displayedAmount = category.value === "0" ? t("portfolio.structure.unvalued") : t("portfolio.structure.knownAmount", { amount });
|
||||
let share = t("portfolio.structure.shareUnavailable");
|
||||
if (percentage !== null) share = percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT
|
||||
? t("portfolio.structure.smallShare")
|
||||
: `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(percentage)} %`;
|
||||
return {
|
||||
name: t(presentation.label),
|
||||
amount: displayedAmount,
|
||||
color: presentation.color,
|
||||
amount: unknown
|
||||
? category.value === "0"
|
||||
? t("portfolio.structure.unvalued")
|
||||
: t("portfolio.structure.knownAmount", { amount })
|
||||
: amount,
|
||||
share:
|
||||
percentage === null
|
||||
? t("portfolio.structure.shareUnavailable")
|
||||
: percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT
|
||||
? t("portfolio.structure.smallShare")
|
||||
: `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(percentage)} %`,
|
||||
name: t(presentation.label),
|
||||
share,
|
||||
value: percentage ?? 0,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
const chartData = rows.filter((row) => row.value > 0);
|
||||
const chartData = rows.filter(row => row.value > 0);
|
||||
return (
|
||||
<PortfolioStructureView
|
||||
unavailable={!structure}
|
||||
currencies={structure?.currencies.map((item) => item.currency) ?? []}
|
||||
currency={selected?.currency ?? null}
|
||||
chartData={chartData}
|
||||
currencies={structure?.currencies.map(item => item.currency) ?? []}
|
||||
currency={selected?.currency ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null}
|
||||
incomplete={isIncomplete}
|
||||
negative={selected?.hasNegativeValues ?? false}
|
||||
onCurrencyChange={setCurrency}
|
||||
rows={rows}
|
||||
chartData={chartData}
|
||||
incomplete={incomplete}
|
||||
negative={selected?.hasNegativeValues ?? false}
|
||||
unavailable={!structure}
|
||||
unvalued={
|
||||
structure?.unvaluedCategories.map((category) =>
|
||||
structure?.unvaluedCategories.map(category =>
|
||||
t("portfolio.structure.unvaluedCategory", {
|
||||
category: t(categoryPresentation[category.type].label),
|
||||
count: category.count,
|
||||
|
||||
+71
-64
@@ -2,123 +2,130 @@ import { DonutChart } from "@mantine/charts";
|
||||
import { Box, Group, Paper, Select, Stack, Text, Title } from "@mantine/core";
|
||||
import { type FC, useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import classes from "./PortfolioStructure.module.css";
|
||||
|
||||
type StructureRow = {
|
||||
name: string;
|
||||
color: string;
|
||||
amount: string;
|
||||
share: string;
|
||||
value: number;
|
||||
};
|
||||
type PortfolioStructureViewProps = {
|
||||
unavailable: boolean;
|
||||
currencies: string[];
|
||||
currency: string | null;
|
||||
onCurrencyChange: (currency: string | null) => void;
|
||||
rows: StructureRow[];
|
||||
chartData: StructureRow[];
|
||||
currencies: string[];
|
||||
currency: null | string;
|
||||
incomplete: boolean;
|
||||
negative: boolean;
|
||||
onCurrencyChange: (currency: null | string) => void;
|
||||
rows: StructureRow[];
|
||||
unavailable: boolean;
|
||||
unvalued: string[];
|
||||
};
|
||||
type StructureRow = {
|
||||
amount: string;
|
||||
color: string;
|
||||
name: string;
|
||||
share: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const titleId = useId();
|
||||
return (
|
||||
<Stack
|
||||
component="section"
|
||||
aria-labelledby={titleId}
|
||||
gap="md"
|
||||
className={classes.section}
|
||||
component="section"
|
||||
gap="md"
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Title order={2} size="h4" id={titleId}>
|
||||
<Group align="center" justify="space-between">
|
||||
<Title id={titleId} order={2} size="h4">
|
||||
{t("portfolio.structure.title")}
|
||||
</Title>
|
||||
{props.currencies.length > 1 ? (
|
||||
{(props.currencies.length > 1) && ((
|
||||
<Select
|
||||
allowDeselect={false}
|
||||
aria-label={t("portfolio.structure.currency")}
|
||||
data={props.currencies}
|
||||
value={props.currency}
|
||||
onChange={props.onCurrencyChange}
|
||||
allowDeselect={false}
|
||||
value={props.currency}
|
||||
w={112}
|
||||
/>
|
||||
) : null}
|
||||
))}
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(
|
||||
props.unavailable
|
||||
? "portfolio.structure.unavailable"
|
||||
: "portfolio.structure.basis",
|
||||
)}
|
||||
</Text>
|
||||
{props.incomplete ? (
|
||||
{!!(props.incomplete) && ((
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">{t("portfolio.structure.incomplete")}</Text>
|
||||
{props.unvalued.map((message) => (
|
||||
<Text size="sm" c="dimmed" key={message}>
|
||||
{props.unvalued.map(message => (
|
||||
<Text c="dimmed" key={message} size="sm">
|
||||
{message}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
{props.negative ? (
|
||||
))}
|
||||
{!!(props.negative) && ((
|
||||
<Text size="sm">{t("portfolio.structure.negative")}</Text>
|
||||
) : null}
|
||||
{!props.unavailable &&
|
||||
!props.chartData.length &&
|
||||
!props.incomplete &&
|
||||
!props.negative ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
))}
|
||||
{!!(!props.unavailable
|
||||
&& props.chartData.length === 0
|
||||
&& !props.incomplete
|
||||
&& !props.negative) && ((
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("portfolio.structure.empty")}
|
||||
</Text>
|
||||
) : null}
|
||||
))}
|
||||
<div
|
||||
className={`${classes.content} ${props.chartData.length > 0 ? classes.layout : ""}`}
|
||||
key={props.currency}
|
||||
className={`${classes.content} ${props.chartData.length ? classes.layout : ""}`}
|
||||
>
|
||||
{props.chartData.length ? (
|
||||
{props.chartData.length > 0 && ((
|
||||
<DonutChart
|
||||
data={props.chartData}
|
||||
size={220}
|
||||
thickness={32}
|
||||
strokeWidth={2}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
chartLabel={props.currency ?? undefined}
|
||||
tooltipDataSource="segment"
|
||||
pieProps={{ isAnimationActive: false }}
|
||||
pieChartProps={{ title: t("portfolio.structure.chartLabel") }}
|
||||
aria-label={t("portfolio.structure.chartLabel")}
|
||||
chartLabel={props.currency ?? undefined}
|
||||
data={props.chartData}
|
||||
endAngle={-270}
|
||||
pieChartProps={{ title: t("portfolio.structure.chartLabel") }}
|
||||
pieProps={{ isAnimationActive: false }}
|
||||
size={220}
|
||||
startAngle={90}
|
||||
strokeWidth={2}
|
||||
thickness={32}
|
||||
tooltipDataSource="segment"
|
||||
tooltipProps={{
|
||||
content: ({ active, payload }) => {
|
||||
const row = props.chartData.find(
|
||||
(item) => item.name === payload?.[0]?.name,
|
||||
item => item.name === payload?.[0]?.name,
|
||||
);
|
||||
return active && row ? (
|
||||
<Paper shadow="sm" p="sm" radius="md">
|
||||
<Text size="sm" fw={600}>
|
||||
{row.name}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{row.amount} · {row.share}
|
||||
</Text>
|
||||
</Paper>
|
||||
) : null;
|
||||
return active && row
|
||||
? (
|
||||
<Paper p="sm" radius="md" shadow="sm">
|
||||
<Text fw={600} size="sm">
|
||||
{row.name}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{row.amount}
|
||||
{" "}
|
||||
·
|
||||
{" "}
|
||||
{row.share}
|
||||
</Text>
|
||||
</Paper>
|
||||
)
|
||||
: undefined;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{props.rows.length ? (
|
||||
))}
|
||||
{props.rows.length > 0 && ((
|
||||
<ul
|
||||
className={classes.list}
|
||||
aria-label={t("portfolio.structure.categories")}
|
||||
className={classes.list}
|
||||
>
|
||||
{props.rows.map((row) => (
|
||||
<li key={row.name} className={classes.row}>
|
||||
{props.rows.map(row => (
|
||||
<li className={classes.row} key={row.name}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Box
|
||||
aria-hidden="true"
|
||||
@@ -128,17 +135,17 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
|
||||
<Text size="sm">{row.name}</Text>
|
||||
</Group>
|
||||
<div className={classes.values}>
|
||||
<Text size="sm" fw={600}>
|
||||
<Text fw={600} size="sm">
|
||||
{row.amount}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text c="dimmed" size="sm">
|
||||
{row.share}
|
||||
</Text>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
|
||||
import type { AccountPagePosition } from "../../AccountPage.vm";
|
||||
|
||||
import { PositionCard } from "./PositionCard.view";
|
||||
|
||||
const syntheticPosition = {
|
||||
figi: null,
|
||||
displayName: "Учебные технологии",
|
||||
type: "STOCK",
|
||||
quantity: "100",
|
||||
currentValue: { currency: "RUB", value: "25000" },
|
||||
averagePurchasePrice: { currency: "RUB", value: "200" },
|
||||
const syntheticPosition = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
absoluteReturn: { currency: "RUB", value: "5000" },
|
||||
averagePurchasePrice: { currency: "RUB", value: "200" },
|
||||
currentValue: { currency: "RUB", value: "25000" },
|
||||
displayName: "Учебные технологии",
|
||||
figi: null,
|
||||
isin: null,
|
||||
ticker: "DEMO",
|
||||
logoName: null,
|
||||
shareText: "20%",
|
||||
logoSrc: null,
|
||||
quantity: "100",
|
||||
returnText: "+5 000 ₽",
|
||||
returnTone: "positive",
|
||||
logoSrc: null,
|
||||
} satisfies AccountPagePosition;
|
||||
shareText: "20%",
|
||||
ticker: "DEMO",
|
||||
type: "STOCK",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */) satisfies AccountPagePosition;
|
||||
|
||||
const meta = {
|
||||
title: "Product/PositionCard",
|
||||
component: PositionCard,
|
||||
tags: ["autodocs"],
|
||||
args: { kind: "stocks", position: syntheticPosition },
|
||||
argTypes: {
|
||||
kind: {
|
||||
control: "inline-radio",
|
||||
@@ -30,6 +32,7 @@ const meta = {
|
||||
},
|
||||
position: { control: false },
|
||||
},
|
||||
component: PositionCard,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
@@ -38,7 +41,8 @@ const meta = {
|
||||
},
|
||||
},
|
||||
},
|
||||
args: { kind: "stocks", position: syntheticPosition },
|
||||
tags: ["autodocs"],
|
||||
title: "Product/PositionCard",
|
||||
} satisfies Meta<typeof PositionCard>;
|
||||
|
||||
export default meta;
|
||||
@@ -57,78 +61,84 @@ export const StockGain: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const BondLoss: Story = {
|
||||
name: "Облигация с отрицательным результатом",
|
||||
args: {
|
||||
kind: "bonds",
|
||||
position: {
|
||||
...syntheticPosition,
|
||||
displayName: "Учебные облигации для долгосрочных накоплений, выпуск 2030",
|
||||
type: "BOND",
|
||||
quantity: "10",
|
||||
currentValue: { currency: "RUB", value: "9500" },
|
||||
averagePurchasePrice: { currency: "RUB", value: "1000" },
|
||||
absoluteReturn: { currency: "RUB", value: "-500" },
|
||||
isin: "DEMO-BOND-2030",
|
||||
ticker: null,
|
||||
shareText: "7,6%",
|
||||
returnText: "−500 ₽",
|
||||
returnTone: "negative",
|
||||
export const BondLoss: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
args: {
|
||||
kind: "bonds",
|
||||
position: {
|
||||
...syntheticPosition,
|
||||
absoluteReturn: { currency: "RUB", value: "-500" },
|
||||
averagePurchasePrice: { currency: "RUB", value: "1000" },
|
||||
currentValue: { currency: "RUB", value: "9500" },
|
||||
displayName: "Учебные облигации для долгосрочных накоплений, выпуск 2030",
|
||||
isin: "DEMO-BOND-2030",
|
||||
quantity: "10",
|
||||
returnText: "−500 ₽",
|
||||
returnTone: "negative",
|
||||
shareText: "7,6%",
|
||||
ticker: null,
|
||||
type: "BOND",
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
name: "Облигация с отрицательным результатом",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Для облигации используется идентификатор выпуска. Длинное имя и отрицательная сумма позволяют проверить компактное представление на узком экране.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
|
||||
export const Other: Story = {
|
||||
name: "Прочий инструмент",
|
||||
args: {
|
||||
kind: "other",
|
||||
position: {
|
||||
...syntheticPosition,
|
||||
displayName: "Учебный фонд",
|
||||
type: "FUND",
|
||||
isin: "DEMO-FUND",
|
||||
ticker: null,
|
||||
export const Other: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
args: {
|
||||
kind: "other",
|
||||
position: {
|
||||
...syntheticPosition,
|
||||
displayName: "Учебный фонд",
|
||||
isin: "DEMO-FUND",
|
||||
ticker: null,
|
||||
type: "FUND",
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
name: "Прочий инструмент",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Компактный вариант для остальных инструментов сохраняет количество, стоимость и пояснение результата без оформления акции или облигации.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
|
||||
export const MissingValuation: Story = {
|
||||
name: "Оценка и идентификаторы отсутствуют",
|
||||
args: {
|
||||
position: {
|
||||
...syntheticPosition,
|
||||
displayName: "Учебная позиция без текущей оценки",
|
||||
currentValue: null,
|
||||
averagePurchasePrice: null,
|
||||
absoluteReturn: null,
|
||||
ticker: null,
|
||||
shareText: null,
|
||||
returnText: null,
|
||||
returnTone: undefined,
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"При отсутствии оценки остаются название и количество. Неизвестная стоимость, доля и результат не изображаются нулевыми значениями.",
|
||||
export const MissingValuation: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
args: {
|
||||
position: {
|
||||
...syntheticPosition,
|
||||
absoluteReturn: null,
|
||||
averagePurchasePrice: null,
|
||||
currentValue: null,
|
||||
displayName: "Учебная позиция без текущей оценки",
|
||||
returnText: null,
|
||||
returnTone: undefined,
|
||||
shareText: null,
|
||||
ticker: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
name: "Оценка и идентификаторы отсутствуют",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"При отсутствии оценки остаются название и количество. Неизвестная стоимость, доля и результат не изображаются нулевыми значениями.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import classes from "../../AccountPage.module.css";
|
||||
|
||||
import type {
|
||||
AccountPagePosition,
|
||||
AccountPagePositionSection,
|
||||
} from "../../AccountPage.vm";
|
||||
|
||||
import classes from "../../AccountPage.module.css";
|
||||
import { PositionLogo } from "../PositionLogo.view";
|
||||
|
||||
type PositionCardProps = {
|
||||
@@ -22,41 +26,37 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
||||
const returnClass = position.returnTone
|
||||
? classes[position.returnTone]
|
||||
: undefined;
|
||||
const returnText =
|
||||
position.returnText && isExpanded
|
||||
? position.returnText
|
||||
: position.returnText
|
||||
? t("portfolio.return", {
|
||||
value: position.returnText,
|
||||
})
|
||||
: null;
|
||||
let returnText = position.returnText;
|
||||
if (returnText && !isExpanded) returnText = t("portfolio.return", { value: returnText });
|
||||
const instrumentTo = position.instrumentTo;
|
||||
const identifier =
|
||||
kind === "stocks" ? (position.ticker ?? position.isin) : position.isin;
|
||||
const identifier
|
||||
= kind === "stocks" ? (position.ticker ?? position.isin) : position.isin;
|
||||
const identifierText = [
|
||||
t("portfolio.quantity", { value: position.quantity }),
|
||||
identifier,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const accountLink = position.account ? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/accounts/${encodeURIComponent(position.account.id)}`}
|
||||
size="xs"
|
||||
>
|
||||
{position.account.name}
|
||||
</Anchor>
|
||||
) : null;
|
||||
const accountLink = position.account
|
||||
? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
size="xs"
|
||||
to={`/accounts/${encodeURIComponent(position.account.id)}`}
|
||||
>
|
||||
{position.account.name}
|
||||
</Anchor>
|
||||
)
|
||||
: undefined;
|
||||
const secondaryText = [
|
||||
position.averagePurchasePrice
|
||||
? t("portfolio.avgPrice", {
|
||||
value: formatMoney(position.averagePurchasePrice),
|
||||
})
|
||||
: null,
|
||||
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null,
|
||||
position.shareText
|
||||
? t("portfolio.share", { value: position.shareText })
|
||||
: null,
|
||||
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
@@ -67,91 +67,93 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
||||
<div className={classes.expandedMain}>
|
||||
<PositionLogo
|
||||
key={position.logoSrc ?? "no-logo"}
|
||||
src={position.logoSrc}
|
||||
label={position.displayName}
|
||||
src={position.logoSrc}
|
||||
to={instrumentTo}
|
||||
/>
|
||||
<Stack gap={2} miw={0} className={classes.expandedIdentity}>
|
||||
{instrumentTo ? (
|
||||
<Anchor component={Link} to={instrumentTo} c="inherit" fw={600}>
|
||||
{position.displayName}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text fw={600} lineClamp={1}>
|
||||
{position.displayName}
|
||||
</Text>
|
||||
)}
|
||||
<Stack className={classes.expandedIdentity} gap={2} miw={0}>
|
||||
{instrumentTo
|
||||
? (
|
||||
<Anchor c="inherit" component={Link} fw={600} to={instrumentTo}>
|
||||
{position.displayName}
|
||||
</Anchor>
|
||||
)
|
||||
: (
|
||||
<Text fw={600} lineClamp={1}>
|
||||
{position.displayName}
|
||||
</Text>
|
||||
)}
|
||||
{accountLink}
|
||||
<Text c="dimmed" size="xs" className={classes.expandedIdentifiers}>
|
||||
<Text c="dimmed" className={classes.expandedIdentifiers} size="xs">
|
||||
{identifierText}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end" className={classes.expandedValue}>
|
||||
{position.currentValue ? (
|
||||
<Stack align="flex-end" className={classes.expandedValue} gap={2}>
|
||||
{!!(position.currentValue) && ((
|
||||
<Text fw={700} size="lg">
|
||||
{formatMoney(position.currentValue)}
|
||||
</Text>
|
||||
) : null}
|
||||
{returnText ? (
|
||||
<Text size="sm" fw={600} className={returnClass}>
|
||||
))}
|
||||
{!!(returnText) && ((
|
||||
<Text className={returnClass} fw={600} size="sm">
|
||||
{returnText}
|
||||
</Text>
|
||||
) : null}
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
{secondaryText ? (
|
||||
<Text c="dimmed" size="xs" className={classes.expandedMeta}>
|
||||
{!!(secondaryText) && ((
|
||||
<Text c="dimmed" className={classes.expandedMeta} size="xs">
|
||||
{secondaryText}
|
||||
</Text>
|
||||
) : null}
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
return (
|
||||
<Card className={classes.tile} withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group align="flex-start" justify="space-between" wrap="nowrap">
|
||||
<Stack gap={2} miw={0}>
|
||||
<Text fw={700} lineClamp={1}>
|
||||
{position.displayName}
|
||||
</Text>
|
||||
{accountLink}
|
||||
{position.isin ? (
|
||||
<Text c="dimmed" size="xs" className={classes.mono}>
|
||||
{!!(position.isin) && ((
|
||||
<Text c="dimmed" className={classes.mono} size="xs">
|
||||
{position.isin}
|
||||
</Text>
|
||||
) : null}
|
||||
))}
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.quantity", { value: position.quantity })}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
{position.currentValue ? (
|
||||
<Stack align="flex-end" gap={2}>
|
||||
{!!(position.currentValue) && ((
|
||||
<Text fw={800} size="lg">
|
||||
{formatMoney(position.currentValue)}
|
||||
</Text>
|
||||
) : null}
|
||||
{returnText ? (
|
||||
<Text size="sm" fw={600} className={returnClass}>
|
||||
))}
|
||||
{!!(returnText) && ((
|
||||
<Text className={returnClass} fw={600} size="sm">
|
||||
{returnText}
|
||||
</Text>
|
||||
) : null}
|
||||
))}
|
||||
</Stack>
|
||||
</Group>
|
||||
{position.averagePurchasePrice || position.shareText ? (
|
||||
{!!(position.averagePurchasePrice || position.shareText) && ((
|
||||
<Group justify="space-between" mt="xs">
|
||||
{position.averagePurchasePrice ? (
|
||||
{!!(position.averagePurchasePrice) && ((
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.avgPrice", {
|
||||
value: formatMoney(position.averagePurchasePrice),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{position.shareText ? (
|
||||
))}
|
||||
{!!(position.shareText) && ((
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.share", { value: position.shareText })}
|
||||
</Text>
|
||||
) : null}
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import classes from "../AccountPage.module.css";
|
||||
|
||||
import type {
|
||||
AccountPagePositionGroup,
|
||||
AccountPagePositionSection,
|
||||
} from "../AccountPage.vm";
|
||||
|
||||
import classes from "../AccountPage.module.css";
|
||||
import { PositionLogo } from "./PositionLogo.view";
|
||||
|
||||
type PositionGroupCardProps = {
|
||||
@@ -22,88 +26,88 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
|
||||
const instrumentTo = identity.instrumentTo;
|
||||
return (
|
||||
<Card className={classes.tile} withBorder>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
{kind !== "other" ? (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{kind !== "other" && ((
|
||||
<PositionLogo
|
||||
key={identity.logoSrc ?? "no-logo"}
|
||||
src={identity.logoSrc}
|
||||
label={identity.displayName}
|
||||
src={identity.logoSrc}
|
||||
to={instrumentTo}
|
||||
/>
|
||||
) : null}
|
||||
))}
|
||||
<Stack gap={2} miw={0}>
|
||||
{instrumentTo ? (
|
||||
<Anchor component={Link} to={instrumentTo} c="inherit" fw={600}>
|
||||
{identity.displayName}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text fw={600}>{identity.displayName}</Text>
|
||||
)}
|
||||
{instrumentTo
|
||||
? (
|
||||
<Anchor c="inherit" component={Link} fw={600} to={instrumentTo}>
|
||||
{identity.displayName}
|
||||
</Anchor>
|
||||
)
|
||||
: (
|
||||
<Text fw={600}>{identity.displayName}</Text>
|
||||
)}
|
||||
<Text c="dimmed" size="xs">
|
||||
{identity.ticker ?? identity.isin ?? identity.figi}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack
|
||||
aria-label={t("portfolio.byAccount")}
|
||||
className={classes.accountPositions}
|
||||
component="ul"
|
||||
gap={0}
|
||||
className={classes.accountPositions}
|
||||
aria-label={t("portfolio.byAccount")}
|
||||
>
|
||||
{group.positions.map((position) => (
|
||||
{group.positions.map(position => (
|
||||
<Stack
|
||||
component="li"
|
||||
key={position.account?.id}
|
||||
gap={4}
|
||||
className={classes.accountPosition}
|
||||
component="li"
|
||||
gap={4}
|
||||
key={position.account?.id}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" gap="xs">
|
||||
{position.account ? (
|
||||
<Group align="flex-start" gap="xs" justify="space-between">
|
||||
{!!(position.account) && ((
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/accounts/${encodeURIComponent(position.account.id)}`}
|
||||
size="sm"
|
||||
className={classes.accountPositionName}
|
||||
component={Link}
|
||||
size="sm"
|
||||
to={`/accounts/${encodeURIComponent(position.account.id)}`}
|
||||
>
|
||||
{position.account.name}
|
||||
</Anchor>
|
||||
) : null}
|
||||
<Text size="sm" fw={600}>
|
||||
))}
|
||||
<Text fw={600} size="sm">
|
||||
{t("portfolio.quantity", { value: position.quantity })}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between" gap="xs">
|
||||
{position.currentValue ? (
|
||||
<Group gap="xs" justify="space-between">
|
||||
{!!(position.currentValue) && ((
|
||||
<Text size="sm">{formatMoney(position.currentValue)}</Text>
|
||||
) : null}
|
||||
{position.returnText ? (
|
||||
))}
|
||||
{!!(position.returnText) && ((
|
||||
<Text
|
||||
size="sm"
|
||||
className={
|
||||
position.returnTone
|
||||
? classes[position.returnTone]
|
||||
: undefined
|
||||
(position.returnTone) ? (classes[position.returnTone]) : undefined
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{kind === "other"
|
||||
? t("portfolio.return", { value: position.returnText })
|
||||
: position.returnText}
|
||||
</Text>
|
||||
) : null}
|
||||
))}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
{position.averagePurchasePrice ? (
|
||||
{!!(position.averagePurchasePrice) && ((
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.avgPrice", {
|
||||
value: formatMoney(position.averagePurchasePrice),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{position.shareText ? (
|
||||
))}
|
||||
{!!(position.shareText) && ((
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.share", { value: position.shareText })}
|
||||
</Text>
|
||||
) : null}
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
))}
|
||||
|
||||
@@ -1,47 +1,52 @@
|
||||
import { Anchor, Avatar } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Anchor, Avatar } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
type PositionLogoProps = {
|
||||
src: string | null;
|
||||
label: string;
|
||||
src: null | string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
const PositionLogo: FC<PositionLogoProps> = (props) => {
|
||||
const { src, label } = props;
|
||||
const { label, src } = props;
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const logo =
|
||||
src && !failed ? (
|
||||
<Avatar
|
||||
src={src}
|
||||
alt={label}
|
||||
size={40}
|
||||
radius="md"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<Avatar size={40} radius="md">
|
||||
{(label.trim()[0] ?? "?").toUpperCase()}
|
||||
</Avatar>
|
||||
);
|
||||
return props.to ? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={props.to}
|
||||
aria-label={label}
|
||||
c="inherit"
|
||||
underline="never"
|
||||
display="block"
|
||||
style={{ borderRadius: "var(--mantine-radius-md)" }}
|
||||
>
|
||||
{logo}
|
||||
</Anchor>
|
||||
) : (
|
||||
logo
|
||||
);
|
||||
const logo
|
||||
= src && !failed
|
||||
? (
|
||||
<Avatar
|
||||
alt={label}
|
||||
onError={() => setFailed(true)}
|
||||
radius="md"
|
||||
size={40}
|
||||
src={src}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Avatar radius="md" size={40}>
|
||||
{(label.trim()[0] ?? "?").toUpperCase()}
|
||||
</Avatar>
|
||||
);
|
||||
return props.to
|
||||
? (
|
||||
<Anchor
|
||||
aria-label={label}
|
||||
c="inherit"
|
||||
component={Link}
|
||||
display="block"
|
||||
style={{ borderRadius: "var(--mantine-radius-md)" }}
|
||||
to={props.to}
|
||||
underline="never"
|
||||
>
|
||||
{logo}
|
||||
</Anchor>
|
||||
)
|
||||
: (
|
||||
logo
|
||||
);
|
||||
};
|
||||
|
||||
export { PositionLogo };
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { SimpleGrid } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
|
||||
import { SimpleGrid } from "@mantine/core";
|
||||
|
||||
import type { AccountPagePositionSection } from "../AccountPage.vm";
|
||||
|
||||
import { PositionCard } from "./PositionCard/PositionCard.view";
|
||||
import { PositionGroupCard } from "./PositionGroupCard.view";
|
||||
|
||||
@@ -10,24 +13,26 @@ type PositionSectionProps = {
|
||||
|
||||
const PositionSection: FC<PositionSectionProps> = (props) => {
|
||||
const { section } = props;
|
||||
const expanded = section.kind !== "other";
|
||||
const isExpanded = section.kind !== "other";
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={expanded ? 1 : { base: 1, sm: 2, md: 3 }}>
|
||||
{section.groups.map((group) =>
|
||||
group.positions.length > 1 ? (
|
||||
<PositionGroupCard
|
||||
key={group.key}
|
||||
group={group}
|
||||
kind={section.kind}
|
||||
/>
|
||||
) : (
|
||||
<PositionCard
|
||||
key={group.key}
|
||||
position={group.positions[0]}
|
||||
kind={section.kind}
|
||||
/>
|
||||
),
|
||||
<SimpleGrid cols={isExpanded ? 1 : { base: 1, md: 3, sm: 2 }}>
|
||||
{section.groups.map(group =>
|
||||
group.positions.length > 1
|
||||
? (
|
||||
<PositionGroupCard
|
||||
group={group}
|
||||
key={group.key}
|
||||
kind={section.kind}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<PositionCard
|
||||
key={group.key}
|
||||
kind={section.kind}
|
||||
position={group.positions[0]}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
|
||||
import type { AccountPageRecentOperations } from "../AccountPage.vm";
|
||||
|
||||
type RecentOperationsProps = {
|
||||
@@ -17,51 +20,49 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{operations.state === "loaded" ? (
|
||||
{(operations.state === "loaded") && ((
|
||||
<>
|
||||
{operations.lastSuccessfulAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{!!(operations.lastSuccessfulAt) && ((
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(operations.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{operations.stale ? (
|
||||
))}
|
||||
{!!(operations.stale) && ((
|
||||
<Text size="xs">{t("investment.stale")}</Text>
|
||||
) : null}
|
||||
{operations.refreshError ? (
|
||||
))}
|
||||
{!!(operations.refreshError) && ((
|
||||
<Text role="status">{t("accounts.refreshError")}</Text>
|
||||
) : null}
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{operations.state === "loading" ? (
|
||||
))}
|
||||
{(operations.state === "loading") && ((
|
||||
<LoadingSkeleton label={t("ledger.recentLoading")}>
|
||||
{[0, 1, 2].map((index) => (
|
||||
<Skeleton key={index} height={48} radius="sm" />
|
||||
{["first", "second", "third"].map(placeholder => (
|
||||
<Skeleton height={48} key={placeholder} radius="sm" />
|
||||
))}
|
||||
</LoadingSkeleton>
|
||||
) : null}
|
||||
{operations.state === "error" ? (
|
||||
))}
|
||||
{(operations.state === "error") && ((
|
||||
<Alert color="red">{t("ledger.recentError")}</Alert>
|
||||
) : null}
|
||||
{operations.state === "empty" ? (
|
||||
))}
|
||||
{(operations.state === "empty") && ((
|
||||
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
|
||||
) : null}
|
||||
{operations.state === "partial" ? (
|
||||
))}
|
||||
{(operations.state === "partial") && ((
|
||||
<Text role="status">{t("investment.partial")}</Text>
|
||||
) : null}
|
||||
{operations.state === "loaded" || operations.state === "partial"
|
||||
? operations.items.map((operation) => (
|
||||
<LedgerEntryRow
|
||||
key={JSON.stringify([
|
||||
operation.account?.id,
|
||||
operation.id,
|
||||
operation.logoName,
|
||||
])}
|
||||
operation={operation}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
))}
|
||||
{(operations.state === "loaded" || operations.state === "partial") && (operations.items.map(operation => (
|
||||
<LedgerEntryRow
|
||||
key={JSON.stringify([
|
||||
operation.account?.id,
|
||||
operation.id,
|
||||
operation.logoName,
|
||||
])}
|
||||
operation={operation}
|
||||
/>
|
||||
)))}
|
||||
<Button component={Link} to={operations.to} variant="subtle">
|
||||
{t("ledger.open")}
|
||||
</Button>
|
||||
|
||||
@@ -2,39 +2,45 @@ 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 type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
import { toAccountPagePositionSections } from "./AccountPage.vm";
|
||||
import { PositionSection } from "./components/PositionSection.view";
|
||||
|
||||
const position: Portfolio["positions"][number] = {
|
||||
account: { id: "a", name: "Основной" },
|
||||
figi: "FIGI-SBER",
|
||||
displayName: "Сбербанк",
|
||||
type: "STOCK",
|
||||
quantity: "1.25",
|
||||
currentValue: { currency: "RUB", value: "375" },
|
||||
averagePurchasePrice: { currency: "RUB", value: "280" },
|
||||
absoluteReturn: { currency: "RUB", value: "25" },
|
||||
isin: "RU0009029540",
|
||||
ticker: "SBER",
|
||||
logoName: null,
|
||||
};
|
||||
const second = {
|
||||
...position,
|
||||
account: { id: "b", name: "ИИС" },
|
||||
quantity: "-2",
|
||||
currentValue: null,
|
||||
averagePurchasePrice: null,
|
||||
absoluteReturn: null,
|
||||
};
|
||||
const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
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",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
const second = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
...position,
|
||||
absoluteReturn: null,
|
||||
account: { id: "b", name: "ИИС" },
|
||||
averagePurchasePrice: null,
|
||||
currentValue: null,
|
||||
quantity: "-2",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
function sections(positions: Portfolio["positions"]) {
|
||||
return toAccountPagePositionSections({
|
||||
lastSuccessfulAt: "2026-09-09T12:00:00Z",
|
||||
isStale: false,
|
||||
totalsByCurrency: [],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [],
|
||||
isStale: false,
|
||||
lastSuccessfulAt: "2026-09-09T12:00:00Z",
|
||||
positions,
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
totalsByCurrency: [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,8 +78,12 @@ describe("Positions grouped by security", () => {
|
||||
it("uses ISIN only when FIGI is absent, never merging by name or ticker", () => {
|
||||
expect(
|
||||
sections([
|
||||
{ ...position, figi: null },
|
||||
{ ...second, figi: null },
|
||||
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{ ...position, figi: null }
|
||||
/* eslint-enable unicorn/no-null */),
|
||||
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{ ...second, figi: null }
|
||||
/* eslint-enable unicorn/no-null */),
|
||||
])[0].groups,
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
@@ -81,8 +91,12 @@ describe("Positions grouped by security", () => {
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
sections([
|
||||
{ ...position, figi: null, isin: null },
|
||||
{ ...second, figi: null, isin: null },
|
||||
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{ ...position, figi: null, isin: null }
|
||||
/* eslint-enable unicorn/no-null */),
|
||||
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{ ...second, figi: null, isin: null }
|
||||
/* eslint-enable unicorn/no-null */),
|
||||
])[0].groups,
|
||||
).toHaveLength(2);
|
||||
});
|
||||
@@ -104,20 +118,20 @@ describe("Positions grouped by security", () => {
|
||||
position,
|
||||
{
|
||||
...second,
|
||||
currentValue: { currency: "USD", value: "1.01" },
|
||||
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) => [
|
||||
group.positions.map(item => [
|
||||
item.quantity,
|
||||
item.currentValue,
|
||||
item.averagePurchasePrice,
|
||||
]),
|
||||
).toEqual(
|
||||
source.map((item) => [
|
||||
source.map(item => [
|
||||
item.quantity,
|
||||
item.currentValue,
|
||||
item.averagePurchasePrice,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import "@mantine/core/styles.css";
|
||||
import "@wealthledger/ui/styles.css";
|
||||
|
||||
import "@/shared/i18n";
|
||||
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 type {
|
||||
AccountPagePosition,
|
||||
AccountPagePositionSection,
|
||||
} from "./AccountPage.vm";
|
||||
|
||||
import { PositionSection } from "./components/PositionSection.view";
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -22,39 +26,42 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
|
||||
const width = window.innerWidth;
|
||||
expect([375, 1280]).toContain(width);
|
||||
for (const kind of kinds) {
|
||||
const positions: AccountPagePosition[] = [1, 2, 3].map((index) => ({
|
||||
figi: null,
|
||||
displayName: `Инструмент ${index}`,
|
||||
type: kind === "stocks" ? "STOCK" : kind === "bonds" ? "BOND" : "FUND",
|
||||
quantity: "10",
|
||||
currentValue: { currency: "RUB", value: "1000" },
|
||||
averagePurchasePrice: null,
|
||||
absoluteReturn: null,
|
||||
isin: null,
|
||||
ticker: null,
|
||||
logoName: null,
|
||||
shareText: null,
|
||||
returnText: null,
|
||||
logoSrc: null,
|
||||
}));
|
||||
const otherType = kind === "bonds" ? "BOND" : "FUND";
|
||||
const positions: AccountPagePosition[] = [1, 2, 3].map(index => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
absoluteReturn: null,
|
||||
averagePurchasePrice: null,
|
||||
currentValue: { currency: "RUB", value: "1000" },
|
||||
displayName: `Инструмент ${index}`,
|
||||
figi: null,
|
||||
isin: null,
|
||||
logoName: null,
|
||||
logoSrc: null,
|
||||
quantity: "10",
|
||||
returnText: null,
|
||||
shareText: null,
|
||||
ticker: null,
|
||||
type: kind === "stocks" ? "STOCK" : otherType,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)));
|
||||
const { unmount } = render(
|
||||
<MantineProvider
|
||||
theme={theme}
|
||||
cssVariablesResolver={cssVariablesResolver}
|
||||
forceColorScheme="light"
|
||||
theme={theme}
|
||||
>
|
||||
<PositionSection
|
||||
section={{
|
||||
kind,
|
||||
groups: positions.map((position) => ({
|
||||
groups: positions.map(position => ({
|
||||
key: position.displayName,
|
||||
positions: [position],
|
||||
})),
|
||||
kind,
|
||||
}}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
const bounds = positions.map((position) =>
|
||||
const bounds = positions.map(position =>
|
||||
screen.getByText(position.displayName).getBoundingClientRect(),
|
||||
);
|
||||
const context = `${kind} at ${width}px`;
|
||||
@@ -64,10 +71,15 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
|
||||
if (index === 0) continue;
|
||||
const previous = bounds[index - 1];
|
||||
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.
|
||||
expect(current.top, context).toBeCloseTo(previous.top, 0);
|
||||
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
|
||||
expect(current.left, context).toBeGreaterThan(previous.right);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
|
||||
expect(current.left, context).toBeCloseTo(previous.left, 0);
|
||||
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
|
||||
expect(current.top, context).toBeGreaterThan(previous.bottom);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user