feat: show persisted investment data and sync state

Consume the provided resource-envelope API snapshot. Preserve financial snapshots during
refresh, observe synchronization without importing, and fence late responses across
account and connection changes.
This commit is contained in:
2026-09-26 21:22:49 +03:00
parent b987fd8e78
commit 4e5f564fa1
81 changed files with 4714 additions and 1420 deletions
@@ -19,6 +19,7 @@ type AccountPageEmptyProps = {
account?: BrokerageAccount;
accountId?: string;
refreshing?: boolean;
sourceError?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
@@ -33,6 +34,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
sourceError={props.sourceError}
refreshError={props.refreshError ?? false}
retry={props.retry}
/>
@@ -23,9 +23,11 @@ import { RecentOperations } from "./RecentOperations.view";
type AccountPageLoadedProps = {
account?: BrokerageAccount;
accountId?: string;
partial?: boolean;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean;
sourceError?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
@@ -40,6 +42,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
sourceError={props.sourceError}
refreshError={props.refreshError ?? false}
retry={props.retry}
/>
@@ -56,11 +59,17 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
},
)}
</Text>
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
<PortfolioStructure
key={props.accountId}
structure={props.portfolio.structure}
/>
{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
defaultValue={[
@@ -4,18 +4,34 @@ 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 } from "@/shared/lib";
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
type IncomeSummaryProps =
| { state: "loading"; to: string }
| { state: "partial"; to: string }
| { state: "error"; to: string; retry: () => void }
| { state: "empty"; to: string }
| { state: "loaded"; to: string; summary: IncomeSummaryData };
| {
state: "loaded";
to: string;
summary: IncomeSummaryData;
lastSuccessfulAt?: string | null;
stale?: boolean;
refreshError?: boolean;
};
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation();
if (props.state === "loading")
return <Text c="dimmed">{t("income.summaryLoading")}</Text>;
if (props.state === "loading" || props.state === "partial")
return (
<Text role="status" c="dimmed">
{t(
props.state === "partial"
? "investment.partial"
: "income.summaryLoading",
)}
</Text>
);
if (props.state === "error")
return (
<StateView
@@ -38,6 +54,17 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
);
return (
<Stack gap="xs">
{props.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
) : null}
{props.stale ? <Text size="xs">{t("investment.stale")}</Text> : null}
{props.refreshError ? (
<Text role="status">{t("income.refreshError")}</Text>
) : null}
{props.summary.totalsByCurrency.map((total) => (
<Paper key={total.currency} p="sm" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
@@ -47,3 +47,8 @@ export const RefreshError: Story = {
name: "Ошибка обновления с retry",
args: { refreshError: true },
};
export const SourceFailure: Story = {
name: "Ошибка синхронизации сохраняет суммы",
args: { refreshError: true, sourceError: true },
};
@@ -3,6 +3,7 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next";
type PortfolioRefreshStateProps = {
sourceError?: boolean;
refreshing: boolean;
refreshError: boolean;
retry?: () => void;
@@ -11,10 +12,17 @@ type PortfolioRefreshStateProps = {
const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
const { refreshing, refreshError, retry } = props;
const { t } = useTranslation();
if (!refreshing && !refreshError) return null;
return (
<>
<Button
onClick={retry}
disabled={refreshing}
variant="light"
w="fit-content"
>
{t(refreshError ? "accounts.retry" : "investment.refresh")}
</Button>
{refreshing ? (
<Group gap="xs" role="status" aria-label={t("portfolio.refreshing")}>
<Loader size="sm" />
@@ -24,12 +32,15 @@ const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
</Group>
) : null}
{refreshError ? (
<Alert color="orange">
<Alert color="orange" role="status" aria-live="polite">
<Stack gap="sm">
<Text>{t("portfolio.refreshError")}</Text>
<Button onClick={retry} variant="light">
{t("accounts.retry")}
</Button>
<Text>
{t(
props.sourceError
? "investment.syncFailed"
: "portfolio.refreshError",
)}
</Text>
</Stack>
</Alert>
) : null}
@@ -4,6 +4,7 @@ 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 = {
@@ -16,6 +17,23 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
return (
<Stack gap="sm">
{operations.state === "loaded" ? (
<>
{operations.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{t("investment.sourceTime", {
date: formatSnapshotTime(operations.lastSuccessfulAt),
})}
</Text>
) : null}
{operations.stale ? (
<Text size="xs">{t("investment.stale")}</Text>
) : null}
{operations.refreshError ? (
<Text role="status">{t("accounts.refreshError")}</Text>
) : null}
</>
) : null}
{operations.state === "loading" ? (
<LoadingSkeleton label={t("ledger.recentLoading")}>
{[0, 1, 2].map((index) => (
@@ -29,7 +47,10 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
{operations.state === "empty" ? (
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
) : null}
{operations.state === "loaded"
{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([