feat: add quiet reading with rare confirmations
Part of the quiet-user-feedback change; docs and specs land in their own repositories.
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
@@ -261,7 +260,7 @@ describe("AccountPage states", () => {
|
||||
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()} />);
|
||||
renderPage(<AccountPageError code={code} />);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Повторить" }),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -274,12 +273,16 @@ describe("AccountPage states", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])("allows retry for %s", (code) => {
|
||||
const retry = vi.fn();
|
||||
renderPage(<AccountPageError code={code} retry={retry} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
});
|
||||
it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])(
|
||||
"shows neutral unavailable without retry for %s",
|
||||
(code) => {
|
||||
renderPage(<AccountPageError code={code} />);
|
||||
expect(screen.getByRole("heading", { name: "Пока недоступно" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Повторить" }))
|
||||
.not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("shows supplied cash separately without adding currency instruments or recalculating totals", () => {
|
||||
renderPage(
|
||||
@@ -405,11 +408,9 @@ describe("AccountPage states", () => {
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Портфель" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"T-Bank временно недоступен. Показан последний успешный снимок.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/T-Bank временно недоступен/))
|
||||
.not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Акции" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Облигации" }),
|
||||
@@ -573,7 +574,7 @@ describe("AccountPage states", () => {
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Не удалось загрузить последние события."),
|
||||
screen.getByText("Пока недоступно"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||||
});
|
||||
@@ -742,13 +743,13 @@ describe("AccountPage states", () => {
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Не удалось загрузить будущие события."),
|
||||
screen.getByText("Данные появятся здесь, когда чтение восстановится."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([503])(
|
||||
"preserves totals and UTC snapshot through refresh error %i, retry and success",
|
||||
"preserves totals quietly through refresh error %i and success",
|
||||
async (status) => {
|
||||
const initialPortfolio: Portfolio
|
||||
= {
|
||||
@@ -821,40 +822,30 @@ describe("AccountPage states", () => {
|
||||
});
|
||||
|
||||
expect(structure).toHaveTextContent("10 ₽");
|
||||
const snapshotText = screen.getByText(/Данные получены/).textContent;
|
||||
expect(snapshotText).toContain("10:00");
|
||||
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: portfolioQueryOptions("account-1").queryKey,
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Не удалось обновить портфель. Показан последний полученный снимок.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"T-Bank временно недоступен. Показан последний успешный снимок.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => expect(requestCount).toBe(2));
|
||||
expect(screen.queryByText(/Не удалось обновить портфель/))
|
||||
.not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/T-Bank временно недоступен/))
|
||||
.not.toBeInTheDocument();
|
||||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByRole("definition").map(node => node.textContent),
|
||||
).toEqual(["1 234,5 ₽", "0,5 $"]);
|
||||
// eslint-disable-next-line jest-dom/prefer-to-have-text-content -- The cached timestamp must remain exactly unchanged.
|
||||
expect(screen.getByText(/Данные получены/).textContent).toBe(
|
||||
snapshotText,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||
expect(screen.queryByRole("button", { name: "Повторить" }))
|
||||
.not.toBeInTheDocument();
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: portfolioQueryOptions("account-1").queryKey,
|
||||
});
|
||||
await waitFor(() => expect(requestCount).toBe(3));
|
||||
expect(structure).toHaveTextContent("10 ₽");
|
||||
expect(
|
||||
screen.getAllByRole("definition").map(node => node.textContent),
|
||||
).toEqual(["1 234,5 ₽", "0,5 $"]);
|
||||
// eslint-disable-next-line jest-dom/prefer-to-have-text-content -- The cached timestamp must remain exactly unchanged.
|
||||
expect(screen.getByText(/Данные получены/).textContent).toBe(
|
||||
snapshotText,
|
||||
);
|
||||
resolveRetry?.(
|
||||
HttpResponse.json(
|
||||
resource(
|
||||
@@ -875,7 +866,7 @@ describe("AccountPage states", () => {
|
||||
screen.getAllByRole("definition").map(node => node.textContent),
|
||||
).toEqual(["-0,5 $", "2,50 €"]),
|
||||
);
|
||||
expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30");
|
||||
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
|
||||
expect(structure).not.toHaveTextContent("10 ₽");
|
||||
expect(structure).toHaveTextContent("Нет положительной стоимости");
|
||||
expect(
|
||||
@@ -916,13 +907,12 @@ describe("AccountPage states", () => {
|
||||
expect(
|
||||
screen.getAllByRole("definition").map(node => node.textContent),
|
||||
).toEqual(["1 234,5 ₽", "0,5 $"]);
|
||||
expect(screen.getByText(/Данные получены/)).toHaveTextContent("10:00");
|
||||
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("status", { name: "Загрузка" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("status", { name: "Обновляем портфель" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("status", { name: "Обновляем портфель" }))
|
||||
.not.toBeInTheDocument();
|
||||
|
||||
resolveRefresh?.(HttpResponse.json(resource(portfolio)));
|
||||
});
|
||||
|
||||
@@ -18,10 +18,6 @@ const AccountPage: FC = () => {
|
||||
incomeSummary={viewModel.incomeSummary}
|
||||
recentEvents={viewModel.recentEvents}
|
||||
recentOperations={viewModel.recentOperations}
|
||||
refreshError={viewModel.refreshError}
|
||||
refreshing={viewModel.refreshing}
|
||||
retry={viewModel.retry}
|
||||
sourceError={viewModel.sourceError}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -35,10 +31,6 @@ const AccountPage: FC = () => {
|
||||
positionSections={viewModel.positionSections}
|
||||
recentEvents={viewModel.recentEvents}
|
||||
recentOperations={viewModel.recentOperations}
|
||||
refreshError={viewModel.refreshError}
|
||||
refreshing={viewModel.refreshing}
|
||||
retry={viewModel.retry}
|
||||
sourceError={viewModel.sourceError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -147,10 +147,10 @@ describe("useAccountPageViewModel", () => {
|
||||
HttpResponse.json(resource(portfolio)),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
||||
HttpResponse.json({ code: "DEMO_FAILURE" }, { status: 400 }),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||
HttpResponse.json({ code: "MOEX_UNAVAILABLE" }, { status: 503 }),
|
||||
HttpResponse.json({ code: "DEMO_FAILURE" }, { status: 400 }),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -208,7 +208,7 @@ it("keeps overview sections independent when their source data is incomplete", a
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes every overview section through one account descriptor and observational reads", async () => {
|
||||
it("keeps overview sections loaded without exposing technical retry", async () => {
|
||||
configureTestAuth();
|
||||
|
||||
const sections: Record<string, unknown>
|
||||
@@ -219,48 +219,17 @@ it("refreshes every overview section through one account descriptor and observat
|
||||
portfolio,
|
||||
};
|
||||
|
||||
const observed: string[] = [];
|
||||
const scopes: unknown[] = [];
|
||||
let version = "1";
|
||||
server.use(
|
||||
...Object.entries(sections).map(([path, data]) =>
|
||||
http.get(
|
||||
`*/api/v1/brokerage-accounts/:accountId/${path}`,
|
||||
({ request }) => {
|
||||
if (new URL(request.url).searchParams.get("sync") === "observe")
|
||||
observed.push(path);
|
||||
|
||||
() => {
|
||||
return HttpResponse.json(
|
||||
resource(data, { dataRevision: version, resourceKey: path }),
|
||||
resource(data, { dataRevision: "1", resourceKey: path }),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||
scopes.push(await request.json());
|
||||
version = "2";
|
||||
|
||||
return HttpResponse.json(
|
||||
{
|
||||
id: "overview",
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
scope: { accountId: "account-1", screen: "account" },
|
||||
},
|
||||
{ status: 202 },
|
||||
);
|
||||
}),
|
||||
http.get("*/api/v1/tbank-sync/:id", () =>
|
||||
|
||||
HttpResponse.json(
|
||||
{
|
||||
errorCode: null,
|
||||
id: "overview",
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
scope: null,
|
||||
state: "succeeded",
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||
@@ -275,11 +244,5 @@ it("refreshes every overview section through one account descriptor and observat
|
||||
state: "loaded",
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
if (result.current.state === "loaded") await result.current.retry();
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(new Set(observed)).toEqual(new Set(Object.keys(sections))),
|
||||
);
|
||||
expect(scopes).toEqual([{ accountId: "account-1", screen: "account" }]);
|
||||
expect(result.current).not.toHaveProperty("retry");
|
||||
});
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
import { useIncomeSummary } from "@/entities/income";
|
||||
import { usePortfolio } from "@/entities/portfolio";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||
import { useInitialAvailability } from "@/shared/lib";
|
||||
|
||||
import {
|
||||
type AccountPagePositionSection,
|
||||
@@ -35,10 +36,6 @@ type AccountPageContent = {
|
||||
partial?: boolean;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
refreshError: boolean;
|
||||
refreshing: boolean;
|
||||
retry: () => Promise<unknown> | undefined;
|
||||
sourceError?: boolean;
|
||||
};
|
||||
|
||||
type AccountPageEmptyViewModel = AccountPageContent & {
|
||||
@@ -54,9 +51,16 @@ type AccountPageLoadedViewModel = AccountPageContent & {
|
||||
type AccountPageViewModel
|
||||
= | AccountPageEmptyViewModel
|
||||
| AccountPageLoadedViewModel
|
||||
| { code?: string; retry: () => Promise<unknown> | undefined; state: "error" }
|
||||
| { code?: string; state: "error" }
|
||||
| { state: "loading" };
|
||||
|
||||
function isPortfolioAccessFailure(code?: string) {
|
||||
return isApiErrorCode(code, ApiErrorCode.Forbidden)
|
||||
|| isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|
||||
|| isApiErrorCode(code, ApiErrorCode.NotFound)
|
||||
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
|
||||
}
|
||||
|
||||
function useAccountPageViewModel(): AccountPageViewModel {
|
||||
const { accountId } = useParams();
|
||||
const portfolio = usePortfolio(accountId);
|
||||
@@ -69,7 +73,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
||||
const income = useIncomeSummary(accountId);
|
||||
const accounts = useBrokerageAccounts();
|
||||
|
||||
const sync = useScreenSync({ accountId, screen: "account" }, [
|
||||
useScreenSync({ accountId, screen: "account" }, [
|
||||
accounts,
|
||||
portfolio,
|
||||
operations,
|
||||
@@ -77,32 +81,28 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
||||
income,
|
||||
]);
|
||||
|
||||
if (!portfolio.data) {
|
||||
if (portfolio.isError || portfolio.meta?.sync.state === "failed")
|
||||
return {
|
||||
code: apiErrorCode(portfolio.error),
|
||||
retry: sync.retry,
|
||||
state: "error" as const,
|
||||
};
|
||||
const code = apiErrorCode(portfolio.error);
|
||||
const isAccessFailure = isPortfolioAccessFailure(code);
|
||||
|
||||
return { state: "loading" as const };
|
||||
}
|
||||
const availability = useInitialAvailability({
|
||||
hasContent: !!portfolio.data,
|
||||
identity: accountId ?? "account",
|
||||
isError: portfolio.isError || portfolio.meta?.sync.state === "failed",
|
||||
isFetching: portfolio.isFetching,
|
||||
});
|
||||
|
||||
if (isAccessFailure) return { code, state: "error" as const };
|
||||
|
||||
if (!portfolio.data)
|
||||
return {
|
||||
state: availability === "unavailable" ? "error" : "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, () => income.refetch(),
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
const incomeSummary = toIncomeSummary(accountId, income);
|
||||
|
||||
if (
|
||||
portfolio.meta?.availability === "complete"
|
||||
@@ -116,19 +116,20 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
||||
recentEvents,
|
||||
recentOperations,
|
||||
state: "empty" as const,
|
||||
...refresh,
|
||||
};
|
||||
|
||||
const isPartial = portfolio.meta?.availability === "partial";
|
||||
|
||||
return {
|
||||
account,
|
||||
accountId,
|
||||
incomeSummary,
|
||||
partial: isPartial,
|
||||
portfolio: portfolio.data,
|
||||
positionSections: toAccountPagePositionSections(portfolio.data),
|
||||
recentEvents,
|
||||
recentOperations,
|
||||
state: "loaded" as const,
|
||||
...refresh,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
|
||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||
|
||||
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||
import { InvestmentRefreshState } from "@/features/tbank-connection";
|
||||
|
||||
import type {
|
||||
AccountPageIncomeSummary,
|
||||
@@ -26,10 +25,6 @@ type AccountPageEmptyProps = {
|
||||
incomeSummary?: AccountPageIncomeSummary;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
refreshError?: boolean;
|
||||
refreshing?: boolean;
|
||||
retry?: () => void;
|
||||
sourceError?: boolean;
|
||||
};
|
||||
|
||||
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||
@@ -45,14 +40,6 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||
<Stack>
|
||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||
|
||||
<InvestmentRefreshState
|
||||
labelScope="portfolio"
|
||||
refreshError={props.refreshError ?? false}
|
||||
refreshing={props.refreshing ?? false}
|
||||
retry={props.retry}
|
||||
sourceError={props.sourceError}
|
||||
/>
|
||||
|
||||
<StateView text={text} title={t("portfolio.emptyTitle")} />
|
||||
|
||||
<PortfolioStructure
|
||||
|
||||
@@ -7,10 +7,10 @@ import { Link } from "react-router";
|
||||
|
||||
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||
|
||||
type AccountPageErrorProps = { code?: string; retry: () => void };
|
||||
type AccountPageErrorProps = { code?: string };
|
||||
|
||||
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
|
||||
const { code, retry } = props;
|
||||
const { code } = props;
|
||||
const { t } = useTranslation();
|
||||
if (isApiErrorCode(code, ApiErrorCode.NotFound))
|
||||
return (
|
||||
@@ -25,6 +25,7 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
|
||||
title={t("portfolio.accountUnavailableTitle")}
|
||||
/>
|
||||
);
|
||||
|
||||
if (
|
||||
isApiErrorCode(code, ApiErrorCode.TbankNotConnected)
|
||||
|| isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|
||||
@@ -41,22 +42,11 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
|
||||
title={t("portfolio.connectionErrorTitle")}
|
||||
/>
|
||||
);
|
||||
if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable))
|
||||
return (
|
||||
<StateView
|
||||
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
|
||||
error
|
||||
text={t("accounts.unavailableText")}
|
||||
title={t("accounts.unavailableTitle")}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<StateView
|
||||
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
|
||||
error
|
||||
text={t("states.errorText")}
|
||||
title={t("states.errorTitle")}
|
||||
text={t("states.unavailableText")}
|
||||
title={t("states.unavailableTitle")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Accordion, Alert, Stack, Text } from "@mantine/core";
|
||||
import { Accordion, Alert, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||
import { InvestmentRefreshState } from "@/features/tbank-connection";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
|
||||
import type { AccountPagePositionSection } from "../../../model/positions";
|
||||
import type {
|
||||
@@ -34,10 +32,6 @@ type AccountPageLoadedProps = {
|
||||
positionSections: AccountPagePositionSection[];
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
refreshError?: boolean;
|
||||
refreshing?: boolean;
|
||||
retry?: () => void;
|
||||
sourceError?: boolean;
|
||||
};
|
||||
|
||||
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
@@ -55,27 +49,6 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
<Stack gap="lg">
|
||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||
|
||||
<InvestmentRefreshState
|
||||
labelScope="portfolio"
|
||||
refreshError={props.refreshError ?? false}
|
||||
refreshing={props.refreshing ?? false}
|
||||
retry={props.retry}
|
||||
sourceError={props.sourceError}
|
||||
/>
|
||||
|
||||
{!!props.portfolio.isStale && <Alert color="blue">{t("portfolio.staleWarning")}</Alert>}
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(
|
||||
props.accountId === "all"
|
||||
? "portfolio.allUpdatedAt"
|
||||
: "portfolio.updatedAt",
|
||||
{
|
||||
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{props.partial
|
||||
? <Alert role="status">{t("investment.partial")}</Alert>
|
||||
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
|
||||
|
||||
@@ -8,30 +8,30 @@ import { Link } from "react-router";
|
||||
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
type IncomeSummaryProps
|
||||
= | {
|
||||
lastSuccessfulAt?: null | string;
|
||||
refreshError?: boolean;
|
||||
stale?: boolean;
|
||||
state: "empty";
|
||||
to: string;
|
||||
}
|
||||
| { state: "error"; to: string }
|
||||
| {
|
||||
state: "loaded";
|
||||
summary: IncomeSummaryData;
|
||||
to: string;
|
||||
}
|
||||
| { retry: () => void; state: "error"; to: string }
|
||||
| { state: "empty"; to: string }
|
||||
| { state: "loading"; to: string }
|
||||
| { state: "partial"; to: string };
|
||||
| {
|
||||
state: "loading";
|
||||
to: string;
|
||||
}
|
||||
| {
|
||||
state: "partial";
|
||||
to: string;
|
||||
};
|
||||
|
||||
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const errorText
|
||||
= props.state === "loaded" && props.refreshError
|
||||
? t("income.refreshError")
|
||||
: null;
|
||||
|
||||
if (props.state === "loading" || props.state === "partial")
|
||||
return (
|
||||
<Text c="dimmed" role="status">
|
||||
@@ -45,9 +45,6 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||
if (props.state === "error")
|
||||
return (
|
||||
<StateView
|
||||
action={
|
||||
<Button onClick={props.retry}>{t("income.summaryRetry")}</Button>
|
||||
}
|
||||
error
|
||||
text={t("income.errorText")}
|
||||
title={t("income.summaryError")}
|
||||
@@ -66,12 +63,6 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<SourceSyncStatus
|
||||
errorText={errorText}
|
||||
lastSuccessfulAt={props.lastSuccessfulAt}
|
||||
stale={props.stale}
|
||||
/>
|
||||
|
||||
{props.summary.totalsByCurrency.map(total => (
|
||||
<Paper key={total.currency} p="sm" withBorder>
|
||||
<Text fw={700}>{formatMoney(total.total)}</Text>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { Button, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { AccountPageRecentOperations } from "../../../model/previews";
|
||||
|
||||
@@ -18,28 +17,15 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const { operations } = props;
|
||||
|
||||
const errorText
|
||||
= operations.state === "loaded" && operations.refreshError
|
||||
? t("accounts.refreshError")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{operations.state === "loaded" && (
|
||||
<SourceSyncStatus
|
||||
errorText={errorText}
|
||||
lastSuccessfulAt={operations.lastSuccessfulAt}
|
||||
stale={operations.stale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{operations.state === "loading" && (
|
||||
<LoadingSkeleton label={t("ledger.recentLoading")}>
|
||||
{["first", "second", "third"].map(placeholder => <Skeleton height={48} key={placeholder} radius="sm" />)}
|
||||
</LoadingSkeleton>
|
||||
)}
|
||||
|
||||
{operations.state === "error" && <Alert color="red">{t("ledger.recentError")}</Alert>}
|
||||
{operations.state === "error" && <Text c="dimmed">{t("states.unavailableTitle")}</Text>}
|
||||
|
||||
{operations.state === "empty" && <Text c="dimmed">{t("ledger.recentEmpty")}</Text>}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user