feat: compact portfolio currency totals
This commit is contained in:
@@ -2,7 +2,6 @@
|
|||||||
max-width: 960px;
|
max-width: 960px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total,
|
|
||||||
.tile,
|
.tile,
|
||||||
.cash {
|
.cash {
|
||||||
border-color: var(--mantine-color-gray-3);
|
border-color: var(--mantine-color-gray-3);
|
||||||
@@ -10,8 +9,51 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.total {
|
.total {
|
||||||
padding: var(--mantine-spacing-md);
|
display: flex;
|
||||||
border-top: 3px solid var(--mantine-color-blue-6);
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border-color: var(--mantine-color-gray-3);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--mantine-color-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalBadge {
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 24px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding-inline: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--mantine-color-blue-0);
|
||||||
|
color: var(--mantine-color-blue-6);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 24px;
|
||||||
|
text-align: center;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalAmount {
|
||||||
|
flex-basis: max-content;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
color: var(--mantine-color-dark-7);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalLeadingDigits {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalAmountSkeleton {
|
||||||
|
max-width: calc(100% - 50px);
|
||||||
|
margin-block: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile,
|
.tile,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router";
|
import { MemoryRouter, Route, Routes } from "react-router";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
@@ -113,6 +113,67 @@ function emptyProps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("AccountPage states", () => {
|
describe("AccountPage states", () => {
|
||||||
|
it("keeps initial placeholders decorative without amounts or focus targets", () => {
|
||||||
|
const { container } = renderPage(<AccountPageLoading />);
|
||||||
|
expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true");
|
||||||
|
expect(screen.queryByRole("definition")).not.toBeInTheDocument();
|
||||||
|
expect(container.querySelector("[tabindex], button, a, input")).toBeNull();
|
||||||
|
expect(container).not.toHaveTextContent(/₽|\$|€|0,00/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["NOT_FOUND", "TBANK_NOT_CONNECTED", "INVALID_TBANK_TOKEN"])(
|
||||||
|
"does not offer retry without cached data for %s",
|
||||||
|
(code) => {
|
||||||
|
renderPage(<AccountPageError code={code} retry={vi.fn()} />);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Повторить" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("link")).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
code === "NOT_FOUND" ? "/accounts" : "/settings",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])(
|
||||||
|
"allows retry for %s",
|
||||||
|
async (code) => {
|
||||||
|
const retry = vi.fn();
|
||||||
|
renderPage(<AccountPageError code={code} retry={retry} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||||
|
expect(retry).toHaveBeenCalledOnce();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("shows only supplied totals despite cash and positions with unknown value", () => {
|
||||||
|
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
||||||
|
expect(
|
||||||
|
screen.getAllByRole("definition").map((node) => node.textContent),
|
||||||
|
).toEqual(["1 234,5 ₽"]);
|
||||||
|
expect(
|
||||||
|
screen.queryByText(/полная оценка|общая стоимость/i),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])(
|
||||||
|
"keeps a nonempty portfolio without totals (positions: %s)",
|
||||||
|
async (hasPositions) => {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
...portfolio,
|
||||||
|
totalsByCurrency: [],
|
||||||
|
positions: hasPositions ? portfolio.positions : [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderAccountRoute(createTestQueryClient());
|
||||||
|
await screen.findByRole("heading", { name: "Портфель", level: 1 });
|
||||||
|
expect(screen.queryByRole("definition")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("renders loading and empty states", () => {
|
it("renders loading and empty states", () => {
|
||||||
renderPage(<AccountPageLoading />);
|
renderPage(<AccountPageLoading />);
|
||||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||||
@@ -512,40 +573,76 @@ describe("AccountPage states", () => {
|
|||||||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the cached portfolio visible with distinct server and refresh warnings", async () => {
|
it.each([503, 404])(
|
||||||
let requestCount = 0;
|
"preserves totals and UTC snapshot through refresh error %i, retry and success",
|
||||||
server.use(
|
async (status) => {
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
let requestCount = 0;
|
||||||
requestCount += 1;
|
let resolveRetry: ((response: Response) => void) | undefined;
|
||||||
if (requestCount === 1) return HttpResponse.json(portfolio);
|
server.use(
|
||||||
return HttpResponse.json(
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
||||||
{ code: "TBANK_UNAVAILABLE" },
|
requestCount += 1;
|
||||||
{ status: 503 },
|
if (requestCount === 1) return HttpResponse.json(portfolio);
|
||||||
);
|
if (requestCount === 3)
|
||||||
}),
|
return new Promise<Response>((resolve) => {
|
||||||
);
|
resolveRetry = resolve;
|
||||||
const queryClient = createTestQueryClient();
|
});
|
||||||
renderAccountRoute(queryClient);
|
return HttpResponse.json(
|
||||||
|
{ code: status === 404 ? "NOT_FOUND" : "TBANK_UNAVAILABLE" },
|
||||||
|
{ status },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
renderAccountRoute(queryClient);
|
||||||
|
|
||||||
await screen.findByText("Вторая");
|
await screen.findByText("Вторая");
|
||||||
void queryClient.invalidateQueries({
|
const snapshotText = screen.getByText(/Данные получены/).textContent;
|
||||||
queryKey: portfolioQueryOptions("account-1").queryKey,
|
expect(snapshotText).toContain("10:00");
|
||||||
});
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: portfolioQueryOptions("account-1").queryKey,
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(
|
await screen.findByText(
|
||||||
"Не удалось обновить портфель. Показан последний полученный снимок.",
|
"Не удалось обновить портфель. Показан последний полученный снимок.",
|
||||||
),
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.getByText(
|
||||||
"T-Bank временно недоступен. Показан последний успешный снимок.",
|
"T-Bank временно недоступен. Показан последний успешный снимок.",
|
||||||
),
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||||||
screen.getByRole("button", { name: "Повторить" }).click();
|
expect(screen.getByRole("definition")).toHaveTextContent("1 234,5 ₽");
|
||||||
await waitFor(() => expect(requestCount).toBe(3));
|
expect(screen.getByText(/Данные получены/).textContent).toBe(
|
||||||
});
|
snapshotText,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||||
|
await waitFor(() => expect(requestCount).toBe(3));
|
||||||
|
expect(screen.getByRole("definition")).toHaveTextContent("1 234,5 ₽");
|
||||||
|
expect(screen.getByText(/Данные получены/).textContent).toBe(
|
||||||
|
snapshotText,
|
||||||
|
);
|
||||||
|
resolveRetry?.(
|
||||||
|
HttpResponse.json({
|
||||||
|
...portfolio,
|
||||||
|
isStale: false,
|
||||||
|
lastSuccessfulAt: "2026-08-14T11:30:00Z",
|
||||||
|
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("definition")).toHaveTextContent("-0,5 $"),
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30");
|
||||||
|
expect(
|
||||||
|
screen.queryByText(/Не удалось обновить портфель/),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText(/T-Bank временно недоступен/),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("keeps the cached portfolio visible during a background refresh", async () => {
|
it("keeps the cached portfolio visible during a background refresh", async () => {
|
||||||
let requestCount = 0;
|
let requestCount = 0;
|
||||||
@@ -569,6 +666,11 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(requestCount).toBe(2));
|
await waitFor(() => expect(requestCount).toBe(2));
|
||||||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("definition")).toHaveTextContent("1 234,5 ₽");
|
||||||
|
expect(screen.getByText(/Данные получены/)).toHaveTextContent("10:00");
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("status", { name: "Загрузка" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("status", { name: "Обновляем портфель" }),
|
screen.getByRole("status", { name: "Обновляем портфель" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
import { Skeleton } from "@mantine/core";
|
import { Paper, SimpleGrid, Skeleton } from "@mantine/core";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LoadingSkeleton } from "@/shared/ui";
|
import { LoadingSkeleton } from "@/shared/ui";
|
||||||
|
import classes from "../AccountPage.module.css";
|
||||||
|
|
||||||
const AccountPageLoading: FC = () => {
|
const AccountPageLoading: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||||
<Skeleton height={32} width="35%" />
|
<Skeleton height={32} width="35%" />
|
||||||
<Skeleton height={120} radius="md" />
|
<SimpleGrid
|
||||||
|
cols={{ base: 1, sm: 2, md: 3 }}
|
||||||
|
spacing={10}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{[0, 1, 2].map((index) => (
|
||||||
|
<Paper key={index} className={classes.total} withBorder>
|
||||||
|
<Skeleton height={24} width={40} radius={4} />
|
||||||
|
<Skeleton
|
||||||
|
height={21}
|
||||||
|
width={180}
|
||||||
|
radius={4}
|
||||||
|
className={classes.totalAmountSkeleton}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
{[0, 1, 2].map((index) => (
|
{[0, 1, 2].map((index) => (
|
||||||
<Skeleton key={index} height={52} radius="sm" />
|
<Skeleton key={index} height={52} radius="sm" />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { PortfolioTotals } from "./PortfolioTotals.view";
|
||||||
|
|
||||||
|
describe("PortfolioTotals currency presentation", () => {
|
||||||
|
it.each([0, 1, 3, 5])(
|
||||||
|
"renders only the %i supplied totals in upstream order",
|
||||||
|
(count) => {
|
||||||
|
const totals = [
|
||||||
|
{ currency: "EUR", value: "3.00" },
|
||||||
|
{ currency: "USD", value: "2.00" },
|
||||||
|
{ currency: "RUB", value: "1.00" },
|
||||||
|
{ currency: "CAD", value: "4.00" },
|
||||||
|
{ currency: "XYZ", value: "5.00" },
|
||||||
|
].slice(0, count);
|
||||||
|
const { container } = render(
|
||||||
|
<MantineProvider>
|
||||||
|
<PortfolioTotals totals={totals} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryAllByRole("definition").map((node) => node.textContent),
|
||||||
|
).toEqual(
|
||||||
|
["3,00 €", "2,00 $", "1,00 ₽", "4,00 CAD", "5,00 XYZ"].slice(0, count),
|
||||||
|
);
|
||||||
|
expect(container.querySelectorAll("dl")).toHaveLength(count);
|
||||||
|
expect(
|
||||||
|
container.querySelector("[tabindex], button, a, input"),
|
||||||
|
).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["RUB", "125000.50", "125 000,50 ₽", "Российский рубль", "₽"],
|
||||||
|
["USD", "320.00", "320,00 $", "Доллар США", "$"],
|
||||||
|
["EUR", "9876.54321", "9 876,54321 €", "Евро", "€"],
|
||||||
|
[
|
||||||
|
"RUB",
|
||||||
|
"123456789012.345678901",
|
||||||
|
"123 456 789 012,345678901 ₽",
|
||||||
|
"Российский рубль",
|
||||||
|
"₽",
|
||||||
|
],
|
||||||
|
["USD", "-1234567.890123", "-1 234 567,890123 $", "Доллар США", "$"],
|
||||||
|
["EUR", "987654321.000001", "987 654 321,000001 €", "Евро", "€"],
|
||||||
|
["USD", "-0.5", "-0,5 $", "Доллар США", "$"],
|
||||||
|
["CAD", "20.00", "20,00 CAD", "CAD", "CAD"],
|
||||||
|
["XYZ", "1.000", "1,000 XYZ", "XYZ", "XYZ"],
|
||||||
|
])(
|
||||||
|
"preserves %s %s and its currency identity",
|
||||||
|
(currency, value, expected, name, symbol) => {
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<PortfolioTotals totals={[{ currency, value }]} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
const term = screen.getByRole("term");
|
||||||
|
expect(term).toHaveTextContent(name);
|
||||||
|
expect(term.querySelector('[aria-hidden="true"]')).toHaveTextContent(
|
||||||
|
symbol,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("definition")).toHaveTextContent(expected);
|
||||||
|
expect(term.parentElement).toBe(
|
||||||
|
screen.getByRole("definition").parentElement,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("keeps USD and CAD as distinct supplied values", () => {
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<PortfolioTotals
|
||||||
|
totals={[
|
||||||
|
{ currency: "USD", value: "1.00" },
|
||||||
|
{ currency: "CAD", value: "2.00" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getAllByRole("definition").map((node) => node.textContent),
|
||||||
|
).toEqual(["1,00 $", "2,00 CAD"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Paper, SimpleGrid, Text } from "@mantine/core";
|
import { Paper, SimpleGrid, Text, VisuallyHidden } from "@mantine/core";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatDecimal } from "@/shared/lib";
|
||||||
import classes from "../AccountPage.module.css";
|
import classes from "../AccountPage.module.css";
|
||||||
|
|
||||||
type PortfolioTotalsProps = {
|
type PortfolioTotalsProps = {
|
||||||
@@ -10,19 +11,49 @@ type PortfolioTotalsProps = {
|
|||||||
|
|
||||||
const PortfolioTotals: FC<PortfolioTotalsProps> = (props) => {
|
const PortfolioTotals: FC<PortfolioTotalsProps> = (props) => {
|
||||||
const { totals } = props;
|
const { totals } = 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") },
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing={10}>
|
||||||
{totals.map((total) => (
|
{totals.map((total) => {
|
||||||
<Paper className={classes.total} key={total.currency} withBorder>
|
const amount = formatDecimal(total.value);
|
||||||
<Text c="dimmed" size="xs">
|
return (
|
||||||
{total.currency}
|
<Paper
|
||||||
</Text>
|
component="dl"
|
||||||
<Text fw={800} size="xl">
|
m={0}
|
||||||
{formatMoney(total)}
|
className={classes.total}
|
||||||
</Text>
|
key={total.currency}
|
||||||
</Paper>
|
withBorder
|
||||||
))}
|
>
|
||||||
|
<Text component="dt" className={classes.totalBadge}>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
{currencies[total.currency]?.symbol ?? total.currency}
|
||||||
|
</span>
|
||||||
|
<VisuallyHidden>
|
||||||
|
{currencies[total.currency]?.name ?? total.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
|
||||||
|
)}{" "}
|
||||||
|
{currencies[total.currency]?.symbol ?? total.currency}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ const ru = {
|
|||||||
},
|
},
|
||||||
account: { title: "Счет" },
|
account: { title: "Счет" },
|
||||||
portfolio: {
|
portfolio: {
|
||||||
|
currencies: {
|
||||||
|
RUB: "Российский рубль",
|
||||||
|
USD: "Доллар США",
|
||||||
|
EUR: "Евро",
|
||||||
|
},
|
||||||
title: "Портфель",
|
title: "Портфель",
|
||||||
emptyTitle: "Портфель пока пуст",
|
emptyTitle: "Портфель пока пуст",
|
||||||
emptyText: "На счете нет позиций и доступных денежных остатков.",
|
emptyText: "На счете нет позиций и доступных денежных остатков.",
|
||||||
|
|||||||
@@ -32,4 +32,4 @@ function instrumentLogoUrl(logoName: string | null | undefined): string | null {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { formatMoney, formatSnapshotTime, instrumentLogoUrl };
|
export { formatDecimal, formatMoney, formatSnapshotTime, instrumentLogoUrl };
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export {
|
export {
|
||||||
|
formatDecimal,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
formatSnapshotTime,
|
formatSnapshotTime,
|
||||||
instrumentLogoUrl,
|
instrumentLogoUrl,
|
||||||
|
|||||||
Reference in New Issue
Block a user