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:
@@ -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>
|
||||
|
||||
+5
@@ -47,3 +47,8 @@ export const RefreshError: Story = {
|
||||
name: "Ошибка обновления с retry",
|
||||
args: { refreshError: true },
|
||||
};
|
||||
|
||||
export const SourceFailure: Story = {
|
||||
name: "Ошибка синхронизации сохраняет суммы",
|
||||
args: { refreshError: true, sourceError: true },
|
||||
};
|
||||
|
||||
+17
-6
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user