refactor: clarify frontend module ownership
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import {
|
||||
type BrokerageEvent,
|
||||
useBrokerageEvents,
|
||||
} from "@/entities/brokerage-event";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { isValidPeriod } from "@/shared/lib";
|
||||
import { isValidPeriod, useUrlPeriod } from "@/shared/lib";
|
||||
|
||||
type AccountEventsPageViewModel
|
||||
= | {
|
||||
@@ -29,18 +29,12 @@ type AccountEventsPageViewModel
|
||||
|
||||
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
const { accountId = "" } = useParams();
|
||||
const [searchParameters, setSearchParameters] = useSearchParams();
|
||||
const urlFrom = searchParameters.get("from");
|
||||
const urlTo = searchParameters.get("to");
|
||||
const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo);
|
||||
|
||||
const appliedFrom = isValidUrlPeriod
|
||||
? urlFrom
|
||||
: null;
|
||||
|
||||
const appliedTo = isValidUrlPeriod
|
||||
? urlTo
|
||||
: null;
|
||||
const {
|
||||
from: appliedFrom,
|
||||
setParameters: setSearchParameters,
|
||||
to: appliedTo,
|
||||
} = useUrlPeriod();
|
||||
|
||||
const [draftPeriod, setDraftPeriod] = useState<[
|
||||
null | string,
|
||||
@@ -48,13 +42,6 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
]>([appliedFrom, appliedTo]);
|
||||
|
||||
const [draftFrom, draftTo] = draftPeriod;
|
||||
useEffect(() => {
|
||||
if (isValidUrlPeriod) return;
|
||||
const normalized = new URLSearchParams(searchParameters);
|
||||
normalized.delete("from");
|
||||
normalized.delete("to");
|
||||
setSearchParameters(normalized, { replace: true });
|
||||
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
|
||||
|
||||
const scope = JSON.stringify([accountId, appliedFrom, appliedTo]);
|
||||
const [previousScope, setPreviousScope] = useState(scope);
|
||||
|
||||
+5
-10
@@ -12,7 +12,10 @@ import {
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { BrokerageEventCard } from "@/entities/brokerage-event";
|
||||
import {
|
||||
BrokerageEventCard,
|
||||
brokerageEventKey,
|
||||
} from "@/entities/brokerage-event";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
|
||||
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
||||
@@ -33,15 +36,7 @@ const AccountEventsPageLoadedEvents: FC<AccountEventsPageLoadedProps> = (
|
||||
{props.events.map(event => (
|
||||
<BrokerageEventCard
|
||||
event={event}
|
||||
key={JSON.stringify([
|
||||
event.account?.id,
|
||||
event.type,
|
||||
event.date,
|
||||
event.instrumentUid,
|
||||
event.figi,
|
||||
event.ticker,
|
||||
event.logoName,
|
||||
])}
|
||||
key={brokerageEventKey(event)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -22,13 +22,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Keep the reused Ledger row's full text readable inside income cards. */
|
||||
.entries :global(.mantine-Text-root) {
|
||||
-webkit-line-clamp: unset;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.comparison {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
@@ -5,13 +5,11 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
import type { IncomeDisplayModel } from "../../../model/display";
|
||||
|
||||
import classes from "../AccountIncomePage.module.css";
|
||||
|
||||
type IncomePaymentsProps = { entries: LoadedProps["display"]["entries"] };
|
||||
|
||||
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
|
||||
type IncomePaymentsProps = { entries: IncomeDisplayModel["entries"] };
|
||||
|
||||
const IncomePayments: FC<IncomePaymentsProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -23,7 +21,7 @@ const IncomePayments: FC<IncomePaymentsProps> = (props) => {
|
||||
<Stack className={classes.entries} component="ul" gap="sm">
|
||||
{props.entries.map(({ key, operation }) => (
|
||||
<li key={key}>
|
||||
<LedgerEntryRow operation={operation} />
|
||||
<LedgerEntryRow operation={operation} wrap />
|
||||
</li>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import {
|
||||
type BrokerageOperation,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@/entities/broker-operation";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||
import { isValidPeriod } from "@/shared/lib";
|
||||
import { isValidPeriod, useUrlPeriod } from "@/shared/lib";
|
||||
|
||||
const operationCategorySet = new Set<string>(operationCategories);
|
||||
|
||||
@@ -44,18 +44,13 @@ type AccountOperationsPageViewModel
|
||||
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||
const { accountId } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParameters, setSearchParameters] = useSearchParams();
|
||||
const urlFrom = searchParameters.get("from");
|
||||
const urlTo = searchParameters.get("to");
|
||||
const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo);
|
||||
|
||||
const appliedFrom = isValidUrlPeriod
|
||||
? urlFrom
|
||||
: null;
|
||||
|
||||
const appliedTo = isValidUrlPeriod
|
||||
? urlTo
|
||||
: null;
|
||||
const {
|
||||
from: appliedFrom,
|
||||
parameters: searchParameters,
|
||||
setParameters: setSearchParameters,
|
||||
to: appliedTo,
|
||||
} = useUrlPeriod();
|
||||
|
||||
const appliedCategories = operationCategories.filter(category =>
|
||||
(searchParameters.get("categories") ?? "").split(",").includes(category),
|
||||
@@ -71,14 +66,6 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||
const [draftCategories, setDraftCategories]
|
||||
= useState<OperationCategory[]>(appliedCategories);
|
||||
|
||||
useEffect(() => {
|
||||
if (isValidUrlPeriod) return;
|
||||
const normalized = new URLSearchParams(searchParameters);
|
||||
normalized.delete("from");
|
||||
normalized.delete("to");
|
||||
setSearchParameters(normalized, { replace: true });
|
||||
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
|
||||
|
||||
const firstPageQuery = {
|
||||
from: dateStart(appliedFrom),
|
||||
to: dateEnd(appliedTo),
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||
import type {
|
||||
BrokerageEvent,
|
||||
BrokerageEventsPreviewState,
|
||||
} from "@/entities/brokerage-event";
|
||||
import type { IncomeSummary } from "@/entities/income";
|
||||
import type { InvestmentResourceMeta } from "@/shared/api";
|
||||
|
||||
const RECENT_EVENTS_LIMIT = 5;
|
||||
|
||||
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 AccountPageRecentEvents = BrokerageEventsPreviewState;
|
||||
|
||||
type AccountPageRecentOperations
|
||||
= | {
|
||||
items: BrokerageOperation[];
|
||||
lastSuccessfulAt?: null | string;
|
||||
refreshError?: boolean;
|
||||
stale?: boolean;
|
||||
state: "loaded" | "partial";
|
||||
to: string;
|
||||
}
|
||||
| {
|
||||
state: "empty"
|
||||
| "error"
|
||||
| "loading";
|
||||
to: string;
|
||||
};
|
||||
|
||||
type PreviewResource<T> = {
|
||||
data?: T;
|
||||
isError: boolean;
|
||||
meta?: Pick<InvestmentResourceMeta,
|
||||
"availability"
|
||||
| "freshness"
|
||||
| "lastSuccessfulAt"
|
||||
| "sync"
|
||||
>;
|
||||
};
|
||||
|
||||
function toIncomeSummary(
|
||||
accountId: string | undefined,
|
||||
income: PreviewResource<IncomeSummary>,
|
||||
retry: () => Promise<unknown>,
|
||||
): 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, state: "error", to };
|
||||
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
|
||||
|
||||
return {
|
||||
lastSuccessfulAt: income.meta?.lastSuccessfulAt,
|
||||
refreshError: income.isError || income.meta?.sync.state === "failed",
|
||||
stale: income.meta?.freshness === "stale",
|
||||
state: "loaded",
|
||||
summary: income.data,
|
||||
to,
|
||||
};
|
||||
}
|
||||
|
||||
function toRecentEvents(
|
||||
accountId: string | undefined,
|
||||
events: PreviewResource<{ events: BrokerageEvent[] }>,
|
||||
): 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: PreviewResource<{ items: BrokerageOperation[] }>,
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
|
||||
export { toIncomeSummary, toRecentEvents, toRecentOperations };
|
||||
export type {
|
||||
AccountPageIncomeSummary,
|
||||
AccountPageRecentEvents,
|
||||
AccountPageRecentOperations,
|
||||
};
|
||||
@@ -22,7 +22,7 @@ import { server } from "@/test/server";
|
||||
import type {
|
||||
AccountPageRecentEvents,
|
||||
AccountPageRecentOperations,
|
||||
} from "./AccountPage.vm";
|
||||
} from "../../model/previews";
|
||||
|
||||
import { toAccountPagePositionSections } from "../../model/positions";
|
||||
import { AccountPage } from "./AccountPage";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
import { useBrokerageOperations } from "@/entities/broker-operation";
|
||||
@@ -9,17 +8,25 @@ import {
|
||||
useBrokerageAccounts,
|
||||
} from "@/entities/brokerage-account";
|
||||
import {
|
||||
type BrokerageEventsPreviewState,
|
||||
useBrokerageEvents,
|
||||
} from "@/entities/brokerage-event";
|
||||
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
|
||||
import { useIncomeSummary } from "@/entities/income";
|
||||
import { usePortfolio } from "@/entities/portfolio";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
|
||||
import { type AccountPagePositionSection, toAccountPagePositionSections } from "../../model/positions";
|
||||
|
||||
const RECENT_EVENTS_LIMIT = 5;
|
||||
import {
|
||||
type AccountPagePositionSection,
|
||||
toAccountPagePositionSections,
|
||||
} from "../../model/positions";
|
||||
import {
|
||||
type AccountPageIncomeSummary,
|
||||
type AccountPageRecentEvents,
|
||||
type AccountPageRecentOperations,
|
||||
toIncomeSummary,
|
||||
toRecentEvents,
|
||||
toRecentOperations,
|
||||
} from "../../model/previews";
|
||||
|
||||
type AccountPageContent = {
|
||||
account?: BrokerageAccount;
|
||||
@@ -38,131 +45,18 @@ 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 AccountPageRecentEvents = BrokerageEventsPreviewState;
|
||||
|
||||
type AccountPageRecentOperations
|
||||
= | {
|
||||
items: BrokerageOperation[];
|
||||
lastSuccessfulAt?: null | string;
|
||||
refreshError?: boolean;
|
||||
stale?: boolean;
|
||||
state: "loaded" | "partial";
|
||||
to: string;
|
||||
}
|
||||
| {
|
||||
state: "empty"
|
||||
| "error"
|
||||
| "loading";
|
||||
to: string;
|
||||
};
|
||||
|
||||
type AccountPageViewModel
|
||||
= | AccountPageEmptyViewModel
|
||||
| AccountPageLoadedViewModel
|
||||
| { code?: string; retry: () => Promise<unknown> | undefined; state: "error" }
|
||||
| { state: "loading" };
|
||||
|
||||
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 {
|
||||
lastSuccessfulAt: income.meta?.lastSuccessfulAt,
|
||||
refreshError: income.isError || income.meta?.sync.state === "failed",
|
||||
stale: income.meta?.freshness === "stale",
|
||||
state: "loaded",
|
||||
summary: income.data,
|
||||
to,
|
||||
};
|
||||
}
|
||||
|
||||
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 useAccountPageViewModel(): AccountPageViewModel {
|
||||
const { accountId } = useParams();
|
||||
const portfolio = usePortfolio(accountId);
|
||||
@@ -197,7 +91,10 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
||||
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 incomeSummary = toIncomeSummary(
|
||||
accountId, income, () => income.refetch(),
|
||||
);
|
||||
|
||||
const refresh = {
|
||||
partial: portfolio.meta?.availability === "partial",
|
||||
@@ -235,9 +132,4 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
type AccountPageIncomeSummary,
|
||||
type AccountPageRecentEvents,
|
||||
type AccountPageRecentOperations,
|
||||
useAccountPageViewModel,
|
||||
};
|
||||
export { useAccountPageViewModel };
|
||||
|
||||
@@ -7,16 +7,16 @@ 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,
|
||||
AccountPageRecentEvents,
|
||||
AccountPageRecentOperations,
|
||||
} from "../AccountPage.vm";
|
||||
} from "../../../model/previews";
|
||||
|
||||
import { AccountHeading } from "./AccountHeading.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
import { PortfolioRefreshState } from "./PortfolioRefreshState/PortfolioRefreshState.view";
|
||||
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
|
||||
import { RecentOperations } from "./RecentOperations.view";
|
||||
|
||||
@@ -45,7 +45,8 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||
<Stack>
|
||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||
|
||||
<PortfolioRefreshState
|
||||
<InvestmentRefreshState
|
||||
labelScope="portfolio"
|
||||
refreshError={props.refreshError ?? false}
|
||||
refreshing={props.refreshing ?? false}
|
||||
retry={props.retry}
|
||||
|
||||
@@ -7,16 +7,20 @@ 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 { AccountPageIncomeSummary, AccountPageRecentEvents, AccountPageRecentOperations } from "../AccountPage.vm";
|
||||
import type {
|
||||
AccountPageIncomeSummary,
|
||||
AccountPageRecentEvents,
|
||||
AccountPageRecentOperations,
|
||||
} from "../../../model/previews";
|
||||
|
||||
import classes from "../AccountPage.module.css";
|
||||
import { AccountHeading } from "./AccountHeading.view";
|
||||
import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
import { PortfolioRefreshState } from "./PortfolioRefreshState/PortfolioRefreshState.view";
|
||||
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
|
||||
import { PositionSection } from "./PositionSection.view";
|
||||
import { RecentOperations } from "./RecentOperations.view";
|
||||
@@ -51,7 +55,8 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
<Stack gap="lg">
|
||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||
|
||||
<PortfolioRefreshState
|
||||
<InvestmentRefreshState
|
||||
labelScope="portfolio"
|
||||
refreshError={props.refreshError ?? false}
|
||||
refreshing={props.refreshing ?? false}
|
||||
retry={props.retry}
|
||||
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
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 = {
|
||||
args: { refreshError: false, refreshing: false, retry: fn() },
|
||||
argTypes: {
|
||||
refreshError: { control: "boolean" },
|
||||
refreshing: { control: "boolean" },
|
||||
retry: { control: false },
|
||||
},
|
||||
component: PortfolioRefreshState,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Обновление уже показанных данных портфеля. Реальные CurrencyCards остаются видимыми вместе с PortfolioRefreshState при refetch и его ошибке. Суммы синтетические; это presentation Client Cached Snapshot, без QueryClient и запроса API. Retry регистрирует локальное действие в Actions и не доказывает восстановление API.",
|
||||
},
|
||||
},
|
||||
},
|
||||
render: arguments_ => (
|
||||
<Stack>
|
||||
<CurrencyCards
|
||||
amounts={[
|
||||
{ currency: "RUB", value: "125000.50" },
|
||||
{ currency: "USD", value: "320" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<PortfolioRefreshState {...arguments_} />
|
||||
</Stack>
|
||||
),
|
||||
tags: ["autodocs"],
|
||||
title: "Product/PortfolioRefreshState",
|
||||
} satisfies Meta<typeof PortfolioRefreshState>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Idle: Story = { name: "Сохранённые данные" };
|
||||
export const Refreshing: Story = {
|
||||
args: { refreshing: true },
|
||||
name: "Фоновое обновление",
|
||||
};
|
||||
export const RefreshError: Story = {
|
||||
args: { refreshError: true },
|
||||
name: "Ошибка обновления с retry",
|
||||
};
|
||||
|
||||
export const SourceFailure: Story = {
|
||||
args: { refreshError: true, sourceError: true },
|
||||
name: "Ошибка синхронизации сохраняет суммы",
|
||||
};
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type PortfolioRefreshStateProps = {
|
||||
refreshError: boolean;
|
||||
refreshing: boolean;
|
||||
retry?: () => void;
|
||||
sourceError?: boolean;
|
||||
};
|
||||
|
||||
const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
|
||||
const { refreshError, refreshing, retry } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disabled={refreshing}
|
||||
onClick={retry}
|
||||
variant="light"
|
||||
w="fit-content"
|
||||
>
|
||||
{t(refreshError ? "accounts.retry" : "investment.refresh")}
|
||||
</Button>
|
||||
|
||||
{!!refreshing && (
|
||||
<Group aria-label={t("portfolio.refreshing")} gap="xs" role="status">
|
||||
<Loader size="sm" />
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("portfolio.refreshing")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!!refreshError && (
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
<Stack gap="sm">
|
||||
<Text>
|
||||
{t(
|
||||
props.sourceError
|
||||
? "investment.syncFailed"
|
||||
: "portfolio.refreshError",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { PortfolioRefreshState };
|
||||
@@ -8,7 +8,7 @@ import { Link } from "react-router";
|
||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
|
||||
import type { AccountPageRecentOperations } from "../AccountPage.vm";
|
||||
import type { AccountPageRecentOperations } from "../../../model/previews";
|
||||
|
||||
type RecentOperationsProps = {
|
||||
operations: AccountPageRecentOperations;
|
||||
|
||||
@@ -5,16 +5,16 @@ import { StateView } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
AccountsRefreshState,
|
||||
type AccountsRefreshStateProps,
|
||||
} from "./AccountsRefreshState.view";
|
||||
InvestmentRefreshState,
|
||||
type InvestmentRefreshStateProps,
|
||||
} from "@/features/tbank-connection";
|
||||
|
||||
const AccountsPageEmpty: FC<AccountsRefreshStateProps> = (props) => {
|
||||
const AccountsPageEmpty: FC<InvestmentRefreshStateProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<AccountsRefreshState {...props} />
|
||||
<InvestmentRefreshState {...props} />
|
||||
|
||||
<StateView
|
||||
text={t("accounts.emptyText")}
|
||||
|
||||
@@ -8,13 +8,12 @@ import {
|
||||
type BrokerageAccountCard,
|
||||
BrokerageAccountList,
|
||||
} from "@/entities/brokerage-account";
|
||||
|
||||
import {
|
||||
AccountsRefreshState,
|
||||
type AccountsRefreshStateProps,
|
||||
} from "./AccountsRefreshState.view";
|
||||
InvestmentRefreshState,
|
||||
type InvestmentRefreshStateProps,
|
||||
} from "@/features/tbank-connection";
|
||||
|
||||
type AccountsPageLoadedProps = AccountsRefreshStateProps & {
|
||||
type AccountsPageLoadedProps = InvestmentRefreshStateProps & {
|
||||
accounts: BrokerageAccountCard[];
|
||||
};
|
||||
|
||||
@@ -32,7 +31,7 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<AccountsRefreshState {...refresh} />
|
||||
<InvestmentRefreshState {...refresh} />
|
||||
|
||||
<Button
|
||||
component={Link}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
|
||||
type AccountsRefreshStateProps = {
|
||||
lastSuccessfulAt?: null | string;
|
||||
refreshError?: boolean;
|
||||
refreshing?: boolean;
|
||||
retry?: () => void;
|
||||
sourceError?: boolean;
|
||||
};
|
||||
|
||||
const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
|
||||
const { refreshError = false, refreshing = false, retry } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!props.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
disabled={refreshing}
|
||||
onClick={retry}
|
||||
variant="light"
|
||||
w="fit-content"
|
||||
>
|
||||
{t(refreshError ? "accounts.retry" : "investment.refresh")}
|
||||
</Button>
|
||||
|
||||
{!!refreshing && (
|
||||
<Group aria-label={t("accounts.refreshing")} gap="xs" role="status">
|
||||
<Loader size="sm" />
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("accounts.refreshing")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!!refreshError && (
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
<Stack gap="sm">
|
||||
<Text>
|
||||
{t(
|
||||
props.sourceError
|
||||
? "investment.syncFailed"
|
||||
: "accounts.refreshError",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountsRefreshState, type AccountsRefreshStateProps };
|
||||
@@ -7,19 +7,21 @@ import {
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
InstrumentChart,
|
||||
instrumentDetailsStyles,
|
||||
InstrumentEvents,
|
||||
InstrumentHeader,
|
||||
InstrumentMetric,
|
||||
InstrumentMetrics,
|
||||
InstrumentPage,
|
||||
InstrumentPosition,
|
||||
InstrumentPrice,
|
||||
InstrumentQuoteStatus,
|
||||
InstrumentTerms,
|
||||
} from "@/features/instrument-details";
|
||||
import {
|
||||
formatDate,
|
||||
@@ -75,11 +77,11 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
{t("bond.percentOfNominal")}
|
||||
</Text>
|
||||
|
||||
<Text className={instrumentDetailsStyles.price}>
|
||||
<InstrumentPrice>
|
||||
{vm.quote?.pricePercent == undefined
|
||||
? t("bond.unknown")
|
||||
: `${formatDecimal(vm.quote.pricePercent)} %`}
|
||||
</Text>
|
||||
</InstrumentPrice>
|
||||
|
||||
{vm.quote?.changePercentagePoints != undefined && (
|
||||
<Text fw={600}>
|
||||
@@ -131,7 +133,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack className={instrumentDetailsStyles.page} gap="lg">
|
||||
<InstrumentPage>
|
||||
<InstrumentHeader
|
||||
logoSrc={vm.logoSrc}
|
||||
name={bond.name}
|
||||
@@ -168,12 +170,9 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className={instrumentDetailsStyles.embeddedSection}>
|
||||
<Title mb="md" order={2} size="h3">
|
||||
{t("bond.terms")}
|
||||
</Title>
|
||||
<InstrumentTerms title={t("bond.terms")}>
|
||||
|
||||
<div className={instrumentDetailsStyles.metricsGrid}>
|
||||
<InstrumentMetrics>
|
||||
<InstrumentMetric
|
||||
label={t("bond.nominal")}
|
||||
value={formatMoney(bond.nominal)}
|
||||
@@ -191,8 +190,8 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
<InstrumentMetric label={t("bond.couponFrequency")} value={couponFrequency} />
|
||||
|
||||
<InstrumentMetric label={t("bond.lot")} value={lotSize} />
|
||||
</div>
|
||||
</div>
|
||||
</InstrumentMetrics>
|
||||
</InstrumentTerms>
|
||||
</InstrumentHeader>
|
||||
|
||||
<BondMetrics metrics={vm.metrics} state={vm.metricsState} />
|
||||
@@ -218,7 +217,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
incomeLabel={t("bond.coupons")}
|
||||
vm={vm}
|
||||
/>
|
||||
</Stack>
|
||||
</InstrumentPage>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,8 +5,18 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BondMetrics as Metrics } from "@/entities/bond";
|
||||
|
||||
import { instrumentDetailsStyles, InstrumentMetric, InstrumentSection, type SectionState } from "@/features/instrument-details";
|
||||
import { formatDate, formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
import {
|
||||
InstrumentMetric,
|
||||
InstrumentMetrics,
|
||||
InstrumentSection,
|
||||
type SectionState,
|
||||
} from "@/features/instrument-details";
|
||||
import {
|
||||
formatDate,
|
||||
formatDecimal,
|
||||
formatMoney,
|
||||
formatSnapshotTime,
|
||||
} from "@/shared/lib";
|
||||
|
||||
type BondMetricsProps = { metrics?: Metrics; state: SectionState };
|
||||
|
||||
@@ -72,7 +82,7 @@ const BondMetrics: FC<BondMetricsProps> = (props) => {
|
||||
<InstrumentSection state={state} title={t("bond.metrics")}>
|
||||
{!!metrics && (
|
||||
<Stack gap="md">
|
||||
<div className={instrumentDetailsStyles.metricsGrid}>
|
||||
<InstrumentMetrics>
|
||||
<InstrumentMetric label={t("bond.creditRating")} value={rating} />
|
||||
|
||||
<InstrumentMetric
|
||||
@@ -92,7 +102,7 @@ const BondMetrics: FC<BondMetricsProps> = (props) => {
|
||||
<InstrumentMetric label={t("bond.effectiveYield")} value={effectiveYield} />
|
||||
|
||||
<InstrumentMetric label={t("bond.couponYield")} value={couponYield} />
|
||||
</div>
|
||||
</InstrumentMetrics>
|
||||
|
||||
{!!metrics.creditRating && (
|
||||
<Group gap="xs">
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Group,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
OperationIcon,
|
||||
} from "@wealthledger/icons";
|
||||
import { EntryCard, LoadingSkeleton } from "@wealthledger/ui";
|
||||
import { type FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import {
|
||||
bankOperationAppearance,
|
||||
} from "@/entities/bank-card";
|
||||
|
||||
import type { CardPageVm } from "./CardPage.vm";
|
||||
|
||||
import classes from "./CardPage.module.css";
|
||||
|
||||
type CardHistoryProps = Pick<CardPageVm,
|
||||
"history"
|
||||
| "period"
|
||||
| "rows"
|
||||
>;
|
||||
|
||||
// eslint-disable-next-line sonarjs/function-return-type -- React renders elements, lists and empty output.
|
||||
const CardHistory: FC<CardHistoryProps> = (props) => {
|
||||
const vm = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (vm.rows) {
|
||||
if (vm.rows.length > 0) {
|
||||
return vm.rows.map((row) => {
|
||||
const details = row.otherCard
|
||||
? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/cards/${row.otherCard}?from=${vm.period.from}&to=${vm.period.to}`}
|
||||
>
|
||||
{t("bank.card")}
|
||||
</Anchor>
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={`${row.title} ${row.item.date}`}
|
||||
className={classes.operation}
|
||||
key={row.item.id}
|
||||
>
|
||||
<EntryCard
|
||||
actions={(
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
aria-label={t("bank.edit")}
|
||||
disabled={!row.editable}
|
||||
onClick={row.onEdit}
|
||||
size="sm"
|
||||
title={t("bank.edit")}
|
||||
variant="subtle"
|
||||
>
|
||||
<EditIcon size={18} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
aria-label={t("bank.delete")}
|
||||
color="red"
|
||||
disabled={!row.editable}
|
||||
onClick={row.onDelete}
|
||||
size="sm"
|
||||
title={t("bank.delete")}
|
||||
variant="subtle"
|
||||
>
|
||||
<DeleteIcon size={18} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
color={row.color}
|
||||
details={details}
|
||||
icon={(
|
||||
<OperationIcon
|
||||
{...bankOperationAppearance(
|
||||
row.item.kind,
|
||||
row.item.categoryName,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
initial={row.title.charAt(0)}
|
||||
metadata={row.metadata}
|
||||
title={row.title}
|
||||
tone={row.tone}
|
||||
value={row.value}
|
||||
valueLabel={t(`bank.kinds.${row.item.kind}`)}
|
||||
wrap
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return <Text>{t("bank.noOperations")}</Text>;
|
||||
}
|
||||
|
||||
if (vm.history.loading) {
|
||||
return (
|
||||
<LoadingSkeleton label={t("bank.loadingHistory")}>
|
||||
<Stack>
|
||||
<Skeleton h={90} />
|
||||
|
||||
<Skeleton h={90} />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export { CardHistory };
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Button,
|
||||
Group,
|
||||
Pagination,
|
||||
@@ -14,10 +13,9 @@ import {
|
||||
AddIcon,
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
OperationIcon,
|
||||
RestoreIcon,
|
||||
} from "@wealthledger/icons";
|
||||
import { EntryCard, LoadingSkeleton, StateView } from "@wealthledger/ui";
|
||||
import { LoadingSkeleton, StateView } from "@wealthledger/ui";
|
||||
import { type FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
@@ -25,114 +23,18 @@ import { Link } from "react-router";
|
||||
import {
|
||||
BankCardFace,
|
||||
BankFeedback,
|
||||
bankOperationAppearance,
|
||||
BankPeriodPicker,
|
||||
BankSummaryView,
|
||||
} from "@/entities/bank-card";
|
||||
|
||||
import type { CardPageVm } from "./CardPage.vm";
|
||||
|
||||
import { CardHistory } from "./CardHistory.view";
|
||||
import classes from "./CardPage.module.css";
|
||||
import { useCardPagePresentationVm } from "./CardPagePresentation.vm";
|
||||
|
||||
type CardPageViewProps = { vm: CardPageVm };
|
||||
|
||||
// eslint-disable-next-line sonarjs/function-return-type -- React renders elements, lists and empty output.
|
||||
const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
const card = vm.card;
|
||||
|
||||
if (!card) return;
|
||||
|
||||
if (vm.rows) {
|
||||
if (vm.rows.length > 0) {
|
||||
return vm.rows.map((row) => {
|
||||
const details = row.otherCard
|
||||
? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/cards/${row.otherCard}?from=${vm.period.from}&to=${vm.period.to}`}
|
||||
>
|
||||
{t("bank.card")}
|
||||
</Anchor>
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={`${row.title} ${row.item.date}`}
|
||||
className={classes.operation}
|
||||
key={row.item.id}
|
||||
>
|
||||
<EntryCard
|
||||
actions={(
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
aria-label={t("bank.edit")}
|
||||
disabled={!row.editable}
|
||||
onClick={row.onEdit}
|
||||
size="sm"
|
||||
title={t("bank.edit")}
|
||||
variant="subtle"
|
||||
>
|
||||
<EditIcon size={18} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
aria-label={t("bank.delete")}
|
||||
color="red"
|
||||
disabled={!row.editable}
|
||||
onClick={row.onDelete}
|
||||
size="sm"
|
||||
title={t("bank.delete")}
|
||||
variant="subtle"
|
||||
>
|
||||
<DeleteIcon size={18} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
color={row.color}
|
||||
details={details}
|
||||
icon={(
|
||||
<OperationIcon
|
||||
{...bankOperationAppearance(
|
||||
row.item.kind,
|
||||
row.item.categoryName,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
initial={row.title.charAt(0)}
|
||||
metadata={row.metadata}
|
||||
title={row.title}
|
||||
tone={row.tone}
|
||||
value={row.value}
|
||||
valueLabel={t(`bank.kinds.${row.item.kind}`)}
|
||||
wrap
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return <Text>{t("bank.noOperations")}</Text>;
|
||||
}
|
||||
|
||||
if (vm.history.loading) {
|
||||
return (
|
||||
<LoadingSkeleton label={t("bank.loadingHistory")}>
|
||||
<Stack>
|
||||
<Skeleton h={90} />
|
||||
|
||||
<Skeleton h={90} />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
|
||||
@@ -291,7 +193,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
gap="lg"
|
||||
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.page ?? "loading"}`}
|
||||
>
|
||||
<CardPageViewHistory {...props} />
|
||||
<CardHistory history={vm.history} period={vm.period} rows={vm.rows} />
|
||||
</Stack>
|
||||
|
||||
{vm.history.error && (
|
||||
|
||||
@@ -6,7 +6,13 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CardPageVm } from "./CardPage.vm";
|
||||
|
||||
function useCardPagePresentationVm(vm: CardPageVm) {
|
||||
function useCardPagePresentationVm(vm: Pick<CardPageVm,
|
||||
"card"
|
||||
| "history"
|
||||
| "pageSize"
|
||||
| "period"
|
||||
| "setPage"
|
||||
>) {
|
||||
const { t } = useTranslation();
|
||||
const card = vm.card;
|
||||
const paginationRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import type { DateInput } from "@mantine/dates";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { useController, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -10,10 +7,9 @@ import type {
|
||||
} from "./OperationForm.types";
|
||||
|
||||
function useOperationFieldsVm(props: Pick<OperationFieldsProps,
|
||||
"dateInputValue"
|
||||
"bindDate"
|
||||
| "kind"
|
||||
| "refundCardId"
|
||||
| "validDate"
|
||||
>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -48,38 +44,13 @@ function useOperationFieldsVm(props: Pick<OperationFieldsProps,
|
||||
props.kind === "transfer" ? "bank.source" : "bank.operationCard",
|
||||
);
|
||||
|
||||
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = value =>
|
||||
props.validDate(props.dateInputValue(value))
|
||||
? props.dateInputValue(value)
|
||||
: null;
|
||||
|
||||
const onDateBlur: ComponentProps<typeof DateInput>["onBlur"] = (event) => {
|
||||
dateField.onChange(props.dateInputValue(event.currentTarget.value));
|
||||
dateField.onBlur();
|
||||
};
|
||||
|
||||
const onDateChange: (value: null | string) => void = value =>
|
||||
dateField.onChange(value ?? "");
|
||||
|
||||
const onDateInput: ComponentProps<typeof DateInput>["onInput"] = event =>
|
||||
dateField.onChange(props.dateInputValue(event.currentTarget.value));
|
||||
|
||||
const dateValue = props.validDate(dateField.value)
|
||||
? dateField.value
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
cardIdField,
|
||||
categoryIdField,
|
||||
dateField,
|
||||
dateParser,
|
||||
dateValue,
|
||||
date: { ...dateField, ...props.bindDate(dateField) },
|
||||
errors,
|
||||
isSubmitting,
|
||||
kindField,
|
||||
onDateBlur,
|
||||
onDateChange,
|
||||
onDateInput,
|
||||
onKindChange,
|
||||
register,
|
||||
sourceCardLabel,
|
||||
|
||||
@@ -43,9 +43,14 @@ const OperationExample: FC<ExampleProps> = (props) => {
|
||||
)
|
||||
}
|
||||
fields={{
|
||||
bindDate: field => ({
|
||||
...field,
|
||||
dateParser: () => "2026-08-01",
|
||||
onChange: value => field.onChange(value ?? ""),
|
||||
onInput: () => {},
|
||||
}),
|
||||
cards: [{ label: "Учебная карта", value: "demo" }],
|
||||
categories: [],
|
||||
dateInputValue: () => "2026-08-01",
|
||||
defaultCategory: "",
|
||||
editing: true,
|
||||
kind,
|
||||
@@ -53,7 +58,6 @@ const OperationExample: FC<ExampleProps> = (props) => {
|
||||
maxDate: () => "2026-08-27",
|
||||
refundCardId: "demo",
|
||||
targets: [],
|
||||
validDate: value => value === "2026-08-01",
|
||||
}}
|
||||
latest={
|
||||
props.conflict
|
||||
@@ -107,7 +111,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Synthetic presentation без API/VM. Date callbacks фиксируют fixture 01.08.2026; денежная validation, recovery и nested category flow проверяются application tests. Actions только локальные.",
|
||||
"Synthetic presentation без API/VM. денежная validation, recovery и nested category flow проверяются application tests. Actions только локальные.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { BankCard, BankOperation } from "@/entities/bank-card";
|
||||
import type {
|
||||
BankCard,
|
||||
bankDateField,
|
||||
BankOperation,
|
||||
} from "@/entities/bank-card";
|
||||
|
||||
type Choice = { label: string; value: string };
|
||||
|
||||
type OperationFieldsProps = {
|
||||
bindDate: typeof bankDateField;
|
||||
cards: Choice[];
|
||||
categories: Choice[];
|
||||
dateInputValue: (value: string) => string;
|
||||
defaultCategory: string;
|
||||
editing: boolean;
|
||||
kind: OperationValues["kind"];
|
||||
@@ -13,7 +17,6 @@ type OperationFieldsProps = {
|
||||
maxDate: () => string;
|
||||
refundCardId: string;
|
||||
targets: Choice[];
|
||||
validDate: (value: string) => boolean;
|
||||
};
|
||||
|
||||
type OperationFormProps = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FC, ReactNode, SubmitEventHandler } from "react";
|
||||
|
||||
import { Button,
|
||||
import {
|
||||
Button,
|
||||
Fieldset,
|
||||
Group,
|
||||
Modal,
|
||||
@@ -26,15 +27,10 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
|
||||
const {
|
||||
cardIdField,
|
||||
categoryIdField,
|
||||
dateField,
|
||||
dateParser,
|
||||
dateValue,
|
||||
date,
|
||||
errors,
|
||||
isSubmitting,
|
||||
kindField,
|
||||
onDateBlur,
|
||||
onDateChange,
|
||||
onDateInput,
|
||||
onKindChange,
|
||||
register,
|
||||
sourceCardLabel,
|
||||
@@ -82,21 +78,16 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
{...dateField}
|
||||
dateParser={dateParser}
|
||||
{...date}
|
||||
error={errors.date?.message}
|
||||
fixOnBlur={false}
|
||||
label={t("bank.date")}
|
||||
maxDate={props.maxDate()}
|
||||
onBlur={onDateBlur}
|
||||
onChange={onDateChange}
|
||||
onInput={onDateInput}
|
||||
popoverProps={{
|
||||
middlewares: { shift: { crossAxis: true } },
|
||||
withinPortal: false,
|
||||
}}
|
||||
required
|
||||
value={dateValue}
|
||||
valueFormat="DD.MM.YYYY"
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,14 +4,12 @@ import { useForm, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
bankDateInputValue,
|
||||
bankDateSchema,
|
||||
bankDateField,
|
||||
bankMoneyValue,
|
||||
type BankOperation,
|
||||
bankOperationKinds,
|
||||
bankToday,
|
||||
useBankCategories,
|
||||
useBankExpenses,
|
||||
useBankFormFocus,
|
||||
useBankOperation,
|
||||
useCreateBankOperation,
|
||||
@@ -25,6 +23,7 @@ import type {
|
||||
} from "./OperationForm.types";
|
||||
|
||||
import { createOperationSchema } from "./OperationForm.schema";
|
||||
import { useRefundPurchase } from "./useRefundPurchase";
|
||||
|
||||
const useOperationFormVm = (props: OperationFormProps) => {
|
||||
useBankFormFocus();
|
||||
@@ -33,7 +32,6 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
const [categoriesOpen, setCategoriesOpen] = useState(false);
|
||||
const [version, setVersion] = useState(props.operation?.version ?? 1);
|
||||
const [latest, setLatest] = useState<BankOperation>();
|
||||
const [sourcePage, setSourcePage] = useState(1);
|
||||
|
||||
const currentQuery = useBankOperation(props.operation?.id);
|
||||
|
||||
@@ -72,20 +70,14 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
)(values, context, options),
|
||||
});
|
||||
|
||||
const sourceId = useWatch({
|
||||
control: methods.control,
|
||||
name: "originalExpenseId",
|
||||
});
|
||||
|
||||
const sourceQuery = useBankOperation(sourceId || undefined);
|
||||
|
||||
const kind = useWatch({ control: methods.control, name: "kind" });
|
||||
|
||||
const expenses = useBankExpenses(
|
||||
kind === "refund" ? props.card.id : undefined,
|
||||
{ page: sourcePage },
|
||||
const purchase = useRefundPurchase(
|
||||
methods.control, props.card.id, kind, Boolean(props.operation),
|
||||
);
|
||||
|
||||
const { sourceId, sourceQuery } = purchase;
|
||||
|
||||
const source = useWatch({ control: methods.control, name: "cardId" });
|
||||
const isPending = !opened || methods.formState.isSubmitting;
|
||||
|
||||
@@ -149,23 +141,6 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
else setError(result.error);
|
||||
};
|
||||
|
||||
const expenseChoice = (item: BankOperation) => ({
|
||||
label: t("bank.expenseChoice", {
|
||||
amount: formatMoney(item.amount),
|
||||
available: formatMoney(item.availableRefund),
|
||||
date: formatDate(item.date),
|
||||
name: item.description || item.categoryName,
|
||||
}),
|
||||
value: item.id,
|
||||
});
|
||||
|
||||
const expenseOptions
|
||||
= expenses.data?.items.map(element => expenseChoice(element)) ?? [];
|
||||
|
||||
const selected = sourceQuery.data
|
||||
? expenseChoice(sourceQuery.data)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
categoriesError: categories.isError
|
||||
? categories.error
|
||||
@@ -173,16 +148,14 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
categoriesOpen,
|
||||
closeCategories: () => setCategoriesOpen(false),
|
||||
error,
|
||||
expensesError: expenses.isError
|
||||
? expenses.error
|
||||
: null,
|
||||
expensesError: purchase.error,
|
||||
methods,
|
||||
onCategoryCreated: (id: string) => {
|
||||
methods.setValue("categoryId", id);
|
||||
setCategoriesOpen(false);
|
||||
},
|
||||
retryCategories: () => void categories.refetch(),
|
||||
retryExpenses: () => void expenses.refetch(),
|
||||
retryExpenses: purchase.retry,
|
||||
retrySource: () => void sourceQuery.refetch(),
|
||||
sourceError: sourceQuery.isError
|
||||
? sourceQuery.error
|
||||
@@ -190,14 +163,12 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
view: {
|
||||
categoriesOpen,
|
||||
editing: Boolean(props.operation),
|
||||
emptyExpenses: Boolean(
|
||||
expenses.data && expenses.data.items.length === 0 && !props.operation,
|
||||
),
|
||||
emptyExpenses: purchase.empty,
|
||||
fields: {
|
||||
bindDate: bankDateField,
|
||||
cards: options,
|
||||
categories:
|
||||
categories.data?.map(c => ({ label: c.name, value: c.id })) ?? [],
|
||||
dateInputValue: bankDateInputValue,
|
||||
defaultCategory: categories.data?.find(c => c.isDefault)?.id ?? "",
|
||||
editing: Boolean(props.operation),
|
||||
kind,
|
||||
@@ -208,7 +179,6 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
maxDate: bankToday,
|
||||
refundCardId: props.card.id,
|
||||
targets: options.filter(c => c.value !== source),
|
||||
validDate: (value: string) => bankDateSchema.safeParse(value).success,
|
||||
},
|
||||
latest: latest
|
||||
? {
|
||||
@@ -221,26 +191,22 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
version: latest.version,
|
||||
}
|
||||
: undefined,
|
||||
loadingExpenses: expenses.isLoading,
|
||||
nextDisabled: !expenses.data?.hasNext || expenses.isFetching,
|
||||
loadingExpenses: purchase.loading,
|
||||
nextDisabled: purchase.nextDisabled,
|
||||
onAddCategory: () => setCategoriesOpen(true),
|
||||
onCancel: () => setOpened(false),
|
||||
onClose: () => !isPending && !categoriesOpen && setOpened(false),
|
||||
onExitTransitionEnd: props.onClose,
|
||||
onNext: () => setSourcePage(sourcePage + 1),
|
||||
onPrevious: () => setSourcePage(sourcePage - 1),
|
||||
onNext: purchase.onNext,
|
||||
onPrevious: purchase.onPrevious,
|
||||
onReload: error && (sourceId || props.operation) ? onReload : undefined,
|
||||
onSubmit,
|
||||
opened,
|
||||
pending: isPending,
|
||||
previousDisabled: sourcePage === 1 || expenses.isFetching,
|
||||
previousDisabled: purchase.previousDisabled,
|
||||
refund: {
|
||||
editing: Boolean(props.operation),
|
||||
expenses:
|
||||
selected
|
||||
&& expenseOptions.every(item => item.value !== selected.value)
|
||||
? [selected, ...expenseOptions]
|
||||
: expenseOptions,
|
||||
expenses: purchase.options,
|
||||
limit: props.operation
|
||||
? refundLimit
|
||||
: sourceQuery.data?.availableRefund,
|
||||
@@ -248,7 +214,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
saveDisabled:
|
||||
(kind === "expense" && !categories.data)
|
||||
|| (kind === "refund" && !sourceQuery.data),
|
||||
sourcePage,
|
||||
sourcePage: purchase.page,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Control } from "react-hook-form";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
type BankOperation,
|
||||
useBankExpenses,
|
||||
useBankOperation,
|
||||
} from "@/entities/bank-card";
|
||||
import { formatDate, formatMoney } from "@/shared/lib";
|
||||
|
||||
import type { OperationValues } from "./OperationForm.types";
|
||||
|
||||
function useRefundPurchase(
|
||||
control: Control<OperationValues>,
|
||||
cardId: string,
|
||||
kind: OperationValues["kind"],
|
||||
isEditing: boolean,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const [page, setPage] = useState(1);
|
||||
const sourceId = useWatch({ control, name: "originalExpenseId" });
|
||||
const sourceQuery = useBankOperation(sourceId || undefined);
|
||||
const expenses = useBankExpenses(kind === "refund" ? cardId : undefined, { page });
|
||||
|
||||
const choice = (item: BankOperation) => ({
|
||||
label: t("bank.expenseChoice", {
|
||||
amount: formatMoney(item.amount),
|
||||
available: formatMoney(item.availableRefund),
|
||||
date: formatDate(item.date),
|
||||
name: item.description || item.categoryName,
|
||||
}),
|
||||
value: item.id,
|
||||
});
|
||||
|
||||
const options = expenses.data?.items.map(item => choice(item)) ?? [];
|
||||
const selected = sourceQuery.data ? choice(sourceQuery.data) : undefined;
|
||||
|
||||
return {
|
||||
empty: Boolean(
|
||||
!isEditing && expenses.data && expenses.data.items.length === 0,
|
||||
),
|
||||
error: expenses.isError ? expenses.error : null,
|
||||
loading: expenses.isLoading,
|
||||
nextDisabled: !expenses.data?.hasNext || expenses.isFetching,
|
||||
onNext: () => setPage(page + 1),
|
||||
onPrevious: () => setPage(page - 1),
|
||||
options: selected && options.every(item => item.value !== selected.value)
|
||||
? [selected, ...options]
|
||||
: options,
|
||||
page,
|
||||
previousDisabled: page === 1 || expenses.isFetching,
|
||||
retry: () => void expenses.refetch(),
|
||||
sourceId,
|
||||
sourceQuery,
|
||||
};
|
||||
}
|
||||
|
||||
export { useRefundPurchase };
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { useAuth } from "@/features/auth";
|
||||
|
||||
function useSettingsPageVm() {
|
||||
const { t } = useTranslation();
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onLogout = async () => {
|
||||
await logout();
|
||||
await navigate("/login", { replace: true });
|
||||
try {
|
||||
await logout();
|
||||
}
|
||||
catch {
|
||||
// The session is already cleared; the route guard owns navigation.
|
||||
}
|
||||
};
|
||||
|
||||
return { onLogout, t };
|
||||
|
||||
@@ -2,45 +2,23 @@ import type { FC } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
instrumentDetailsStyles,
|
||||
InstrumentHeader,
|
||||
InstrumentMetric,
|
||||
InstrumentQuoteStatus,
|
||||
InstrumentPage,
|
||||
InstrumentChart as StockChart,
|
||||
InstrumentEvents as StockEvents,
|
||||
InstrumentPosition as StockPosition,
|
||||
InstrumentSection as StockSection,
|
||||
} from "@/features/instrument-details";
|
||||
import {
|
||||
formatDate,
|
||||
formatDecimal,
|
||||
formatMoney,
|
||||
} from "@/shared/lib";
|
||||
|
||||
import type { StockPageVm } from "./StockPage.vm";
|
||||
|
||||
import { StockCompany } from "./components/StockCompany.view";
|
||||
import { StockDividends } from "./components/StockDividends.view";
|
||||
import classes from "./StockPage.module.css";
|
||||
|
||||
const compactMetricFormatter = new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: 2,
|
||||
notation: "compact",
|
||||
});
|
||||
|
||||
const metricFormatter = new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: 2,
|
||||
notation: "standard",
|
||||
});
|
||||
import { StockQuote } from "./components/StockQuote.view";
|
||||
|
||||
type StockPageViewProps = { vm: StockPageVm };
|
||||
|
||||
@@ -59,96 +37,8 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
</>
|
||||
);
|
||||
|
||||
const appearanceClass = vm.quote?.change?.value.startsWith("-")
|
||||
? classes.negative
|
||||
: classes.positive;
|
||||
|
||||
let quoteContent;
|
||||
|
||||
if (vm.quoteState.loading) {
|
||||
quoteContent = (
|
||||
<LoadingSkeleton label={t("stock.loadingPrice")}>
|
||||
<Skeleton h={55} mt="lg" w="50%" />
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
}
|
||||
else {
|
||||
quoteContent = (
|
||||
<div aria-live="polite">
|
||||
<Text className={instrumentDetailsStyles.price} mt="lg">
|
||||
{formatMoney(vm.quote?.price)}
|
||||
</Text>
|
||||
|
||||
{!!vm.quote?.change && (
|
||||
<Text className={appearanceClass} fw={600}>
|
||||
{vm.quote.change.value.startsWith("-") ? "" : "+"}
|
||||
|
||||
{formatMoney(vm.quote.change)}
|
||||
|
||||
{vm.quote.changePercent === null
|
||||
? ""
|
||||
: ` (${formatDecimal(vm.quote.changePercent)} %)`}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{vm.quote?.previousCloseDate
|
||||
? (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.changeBase", {
|
||||
date: formatDate(vm.quote.previousCloseDate),
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
: (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.noComparison")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group gap="sm" mt="sm">
|
||||
<InstrumentQuoteStatus quote={vm.quote} />
|
||||
</Group>
|
||||
|
||||
{!!vm.quote?.unavailable.length && (
|
||||
<Text c="dimmed" mt="xs" size="xs">
|
||||
{t("stock.partialQuote")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!vm.quoteState.refreshing && (
|
||||
<Text c="dimmed" role="status" size="xs">
|
||||
{t("accounts.refreshing")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let companyMetricsContent;
|
||||
|
||||
if (vm.company) {
|
||||
if (vm.company.metrics.length > 0) {
|
||||
companyMetricsContent = (
|
||||
<div className={instrumentDetailsStyles.metricsGrid}>
|
||||
{vm.company.metrics.map((metric) => {
|
||||
const label = [t(`stock.metrics.${metric.key}`), metric.period].filter(Boolean).join(" · ");
|
||||
const formatter = metric.unit === "money" ? compactMetricFormatter : metricFormatter;
|
||||
const value = `${formatter.format(metric.value)} ${metricUnit(metric, t("stock.currencyUnknown"))}`;
|
||||
|
||||
return (
|
||||
<InstrumentMetric key={metric.key} label={label} value={value} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
else {
|
||||
companyMetricsContent = <Text c="dimmed">{t("stock.noFundamentals")}</Text>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack className={instrumentDetailsStyles.page} gap="lg">
|
||||
<InstrumentPage>
|
||||
<InstrumentHeader
|
||||
logoSrc={vm.logoSrc}
|
||||
name={stock.name}
|
||||
@@ -169,52 +59,13 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{quoteContent}
|
||||
<StockQuote quote={vm.quote} quoteState={vm.quoteState} />
|
||||
|
||||
<StockSection
|
||||
embedded
|
||||
state={vm.companyState}
|
||||
title={t("stock.company")}
|
||||
>
|
||||
<Group gap="xl" mb="md">
|
||||
{!!stock.sector && (
|
||||
<Text size="sm">
|
||||
{t("stock.sector")}:{" "}
|
||||
{t(`stock.sectors.${stock.sector}`, {
|
||||
defaultValue: stock.sector,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{stock.lot !== null && <Text size="sm">{t("stock.lot", { value: stock.lot })}</Text>}
|
||||
</Group>
|
||||
|
||||
{!!vm.company && (
|
||||
<Stack gap="lg">
|
||||
{vm.company.unavailable.length > 0 && (
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">{t("stock.partialCompany")}</Text>
|
||||
|
||||
<Button
|
||||
onClick={vm.companyState.retry}
|
||||
size="xs"
|
||||
variant="default"
|
||||
>
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{companyMetricsContent}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.periodNote")}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</StockSection>
|
||||
<StockCompany
|
||||
company={vm.company}
|
||||
companyState={vm.companyState}
|
||||
stock={stock}
|
||||
/>
|
||||
</InstrumentHeader>
|
||||
|
||||
<StockChart vm={vm} />
|
||||
@@ -229,20 +80,8 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
/>
|
||||
|
||||
<StockEvents vm={vm} />
|
||||
</Stack>
|
||||
</InstrumentPage>
|
||||
);
|
||||
};
|
||||
|
||||
function metricUnit(
|
||||
metric: NonNullable<StockPageVm["company"]>["metrics"][number],
|
||||
unknownCurrency: string,
|
||||
) {
|
||||
if (metric.unit === "money")
|
||||
return metric.currency === "RUB"
|
||||
? "₽"
|
||||
: (metric.currency ?? unknownCurrency);
|
||||
|
||||
return metric.unit === "percent" ? "%" : "×";
|
||||
}
|
||||
|
||||
export { StockPageView };
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
InstrumentMetric,
|
||||
InstrumentMetrics,
|
||||
InstrumentSection as StockSection,
|
||||
} from "@/features/instrument-details";
|
||||
|
||||
import type { StockPageVm } from "../StockPage.vm";
|
||||
|
||||
const compactMetricFormatter = new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: 2,
|
||||
notation: "compact",
|
||||
});
|
||||
|
||||
const metricFormatter = new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: 2,
|
||||
notation: "standard",
|
||||
});
|
||||
|
||||
type StockCompanyProps = Pick<StockPageVm, "company" | "companyState"> & {
|
||||
stock: NonNullable<StockPageVm["stock"]>;
|
||||
};
|
||||
|
||||
const StockCompany: FC<StockCompanyProps> = (props) => {
|
||||
const { stock } = props;
|
||||
const { t } = useTranslation();
|
||||
let companyMetricsContent;
|
||||
|
||||
if (props.company) {
|
||||
if (props.company.metrics.length > 0) {
|
||||
companyMetricsContent = (
|
||||
<InstrumentMetrics>
|
||||
{props.company.metrics.map((metric) => {
|
||||
const label = [t(`stock.metrics.${metric.key}`), metric.period].filter(Boolean).join(" · ");
|
||||
const formatter = metric.unit === "money" ? compactMetricFormatter : metricFormatter;
|
||||
const value = `${formatter.format(metric.value)} ${metricUnit(metric, t("stock.currencyUnknown"))}`;
|
||||
|
||||
return (
|
||||
<InstrumentMetric key={metric.key} label={label} value={value} />
|
||||
);
|
||||
})}
|
||||
</InstrumentMetrics>
|
||||
);
|
||||
}
|
||||
else {
|
||||
companyMetricsContent = <Text c="dimmed">{t("stock.noFundamentals")}</Text>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StockSection
|
||||
embedded
|
||||
state={props.companyState}
|
||||
title={t("stock.company")}
|
||||
>
|
||||
<Group gap="xl" mb="md">
|
||||
{!!stock.sector && (
|
||||
<Text size="sm">
|
||||
{t("stock.sector")}:{" "}
|
||||
{t(`stock.sectors.${stock.sector}`, {
|
||||
defaultValue: stock.sector,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{stock.lot !== null && <Text size="sm">{t("stock.lot", { value: stock.lot })}</Text>}
|
||||
</Group>
|
||||
|
||||
{!!props.company && (
|
||||
<Stack gap="lg">
|
||||
{props.company.unavailable.length > 0 && (
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">{t("stock.partialCompany")}</Text>
|
||||
|
||||
<Button
|
||||
onClick={props.companyState.retry}
|
||||
size="xs"
|
||||
variant="default"
|
||||
>
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{companyMetricsContent}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.periodNote")}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</StockSection>
|
||||
);
|
||||
};
|
||||
|
||||
function metricUnit(
|
||||
metric: NonNullable<StockPageVm["company"]>["metrics"][number],
|
||||
unknownCurrency: string,
|
||||
) {
|
||||
if (metric.unit === "money")
|
||||
return metric.currency === "RUB"
|
||||
? "₽"
|
||||
: (metric.currency ?? unknownCurrency);
|
||||
|
||||
return metric.unit === "percent" ? "%" : "×";
|
||||
}
|
||||
|
||||
export { StockCompany };
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import {
|
||||
Group,
|
||||
Skeleton,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
InstrumentPrice,
|
||||
InstrumentQuoteStatus,
|
||||
} from "@/features/instrument-details";
|
||||
import {
|
||||
formatDate,
|
||||
formatDecimal,
|
||||
formatMoney,
|
||||
} from "@/shared/lib";
|
||||
|
||||
import type { StockPageVm } from "../StockPage.vm";
|
||||
|
||||
import classes from "../StockPage.module.css";
|
||||
|
||||
type StockQuoteProps = Pick<StockPageVm, "quote" | "quoteState">;
|
||||
|
||||
const StockQuote: FC<StockQuoteProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const appearanceClass = props.quote?.change?.value.startsWith("-")
|
||||
? classes.negative
|
||||
: classes.positive;
|
||||
|
||||
let quoteContent;
|
||||
|
||||
if (props.quoteState.loading) {
|
||||
quoteContent = (
|
||||
<LoadingSkeleton label={t("stock.loadingPrice")}>
|
||||
<Skeleton h={55} mt="lg" w="50%" />
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
}
|
||||
else {
|
||||
quoteContent = (
|
||||
<div aria-live="polite">
|
||||
<InstrumentPrice mt="lg">
|
||||
{formatMoney(props.quote?.price)}
|
||||
</InstrumentPrice>
|
||||
|
||||
{!!props.quote?.change && (
|
||||
<Text className={appearanceClass} fw={600}>
|
||||
{props.quote.change.value.startsWith("-") ? "" : "+"}
|
||||
|
||||
{formatMoney(props.quote.change)}
|
||||
|
||||
{props.quote.changePercent === null
|
||||
? ""
|
||||
: ` (${formatDecimal(props.quote.changePercent)} %)`}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{props.quote?.previousCloseDate
|
||||
? (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.changeBase", {
|
||||
date: formatDate(props.quote.previousCloseDate),
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
: (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.noComparison")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group gap="sm" mt="sm">
|
||||
<InstrumentQuoteStatus quote={props.quote} />
|
||||
</Group>
|
||||
|
||||
{!!props.quote?.unavailable.length && (
|
||||
<Text c="dimmed" mt="xs" size="xs">
|
||||
{t("stock.partialQuote")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!props.quoteState.refreshing && (
|
||||
<Text c="dimmed" role="status" size="xs">
|
||||
{t("accounts.refreshing")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return quoteContent;
|
||||
};
|
||||
|
||||
export { StockQuote };
|
||||
Reference in New Issue
Block a user