refactor: clarify frontend model ownership
This commit is contained in:
@@ -146,12 +146,14 @@ describe("Upcoming event date filters", () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useAccountEventsPageViewModel("all"),
|
||||
() => useAccountEventsPageViewModel(),
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[`?${search}`]}>
|
||||
{children}
|
||||
<MemoryRouter initialEntries={[`/accounts/all/events?${search}`]}>
|
||||
<Routes>
|
||||
<Route element={children} path="/accounts/:accountId/events" />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
@@ -326,10 +328,14 @@ it("retains the previous complete forecast and timestamp when its source refresh
|
||||
);
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const { result } = renderHook(() => useAccountEventsPageViewModel("all"), {
|
||||
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
<MemoryRouter initialEntries={["/accounts/all/events"]}>
|
||||
<Routes>
|
||||
<Route element={children} path="/accounts/:accountId/events" />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -5,10 +5,8 @@ import { AccountEventsPageError } from "./components/AccountEventsPageError.view
|
||||
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
|
||||
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
|
||||
|
||||
type AccountEventsPageProps = { accountId?: string };
|
||||
|
||||
const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
|
||||
const viewModel = useAccountEventsPageViewModel(props.accountId);
|
||||
const AccountEventsPage: FC = () => {
|
||||
const viewModel = useAccountEventsPageViewModel();
|
||||
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
|
||||
|
||||
return viewModel.state === "error"
|
||||
|
||||
@@ -27,11 +27,8 @@ type AccountEventsPageViewModel
|
||||
| { onReset: () => void; retry: () => void; state: "error" }
|
||||
| { state: "loading" };
|
||||
|
||||
function useAccountEventsPageViewModel(
|
||||
scopeId?: string,
|
||||
): AccountEventsPageViewModel {
|
||||
const { accountId: routeAccountId } = useParams();
|
||||
const accountId = scopeId ?? routeAccountId ?? "";
|
||||
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||
const { accountId = "" } = useParams();
|
||||
const [searchParameters, setSearchParameters] = useSearchParams();
|
||||
const urlFrom = searchParameters.get("from");
|
||||
const urlTo = searchParameters.get("to");
|
||||
@@ -59,13 +56,11 @@ function useAccountEventsPageViewModel(
|
||||
setSearchParameters(normalized, { replace: true });
|
||||
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
|
||||
|
||||
const [previousPeriod, setPreviousPeriod] = useState([
|
||||
appliedFrom,
|
||||
appliedTo,
|
||||
]);
|
||||
const scope = JSON.stringify([accountId, appliedFrom, appliedTo]);
|
||||
const [previousScope, setPreviousScope] = useState(scope);
|
||||
|
||||
if (previousPeriod[0] !== appliedFrom || previousPeriod[1] !== appliedTo) {
|
||||
setPreviousPeriod([appliedFrom, appliedTo]);
|
||||
if (previousScope !== scope) {
|
||||
setPreviousScope(scope);
|
||||
setDraftPeriod([appliedFrom, appliedTo]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
|
||||
import type { IncomeAnalytics, IncomeAnalyticsMeta } from "@/entities/income";
|
||||
|
||||
import { formatDate } from "@/shared/lib";
|
||||
|
||||
@@ -29,8 +29,6 @@ type IncomeDisplayModel = {
|
||||
yearNotice: ComparisonNotice;
|
||||
};
|
||||
|
||||
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
|
||||
|
||||
function comparisonDisplay(
|
||||
period: IncomePeriod,
|
||||
comparison: IncomeAnalytics["yearComparison"],
|
||||
@@ -63,7 +61,7 @@ function comparisonDisplay(
|
||||
|
||||
function comparisonNotice(
|
||||
dependency:
|
||||
NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number] | undefined,
|
||||
IncomeAnalyticsMeta["dependencies"][number] | undefined,
|
||||
unavailable: "previousUnavailable" | "yearUnavailable",
|
||||
): ComparisonNotice {
|
||||
if (!dependency || dependency.unavailableReason) return unavailable;
|
||||
@@ -98,7 +96,7 @@ function formatIncomeChange(
|
||||
|
||||
function toIncomeDisplayModel(
|
||||
analytics: IncomeAnalytics,
|
||||
meta?: IncomeMeta,
|
||||
meta?: IncomeAnalyticsMeta,
|
||||
): IncomeDisplayModel | null {
|
||||
const instruments = new Map<
|
||||
string,
|
||||
|
||||
@@ -5,10 +5,8 @@ import { AccountIncomePageError } from "./components/AccountIncomePageError.view
|
||||
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
|
||||
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
|
||||
|
||||
type AccountIncomePageProps = { accountId?: string };
|
||||
|
||||
const AccountIncomePage: FC<AccountIncomePageProps> = (props) => {
|
||||
const viewModel = useAccountIncomePageViewModel(props.accountId);
|
||||
const AccountIncomePage: FC = () => {
|
||||
const viewModel = useAccountIncomePageViewModel();
|
||||
if (viewModel.state === "loading") return <AccountIncomePageLoading />;
|
||||
|
||||
return viewModel.state === "error"
|
||||
|
||||
@@ -44,11 +44,8 @@ type AccountIncomePageViewModel
|
||||
}
|
||||
| { state: "loading" };
|
||||
|
||||
const useAccountIncomePageViewModel = (
|
||||
scopeId?: string,
|
||||
): AccountIncomePageViewModel => {
|
||||
const { accountId: routeAccountId } = useParams();
|
||||
const accountId = scopeId ?? routeAccountId ?? "";
|
||||
const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
||||
const { accountId = "" } = useParams();
|
||||
const [searchParameters, setSearchParameters] = useSearchParams();
|
||||
|
||||
const requested = useMemo(
|
||||
@@ -83,7 +80,7 @@ const useAccountIncomePageViewModel = (
|
||||
}, [period, requested, setSearchParameters]);
|
||||
useEffect(() => {
|
||||
form.reset(draftPeriod(period));
|
||||
}, [form, period]);
|
||||
}, [accountId, form, period]);
|
||||
|
||||
if (analytics.isLoading || !analytics.data) {
|
||||
return analytics.isError || analytics.meta?.sync.state === "failed"
|
||||
@@ -117,7 +114,7 @@ const useAccountIncomePageViewModel = (
|
||||
analytics: analytics.data,
|
||||
display,
|
||||
form,
|
||||
formRevision: `${period.from}:${period.to}:${resetRevision}`,
|
||||
formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`,
|
||||
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
|
||||
onReset,
|
||||
onSubmit,
|
||||
|
||||
@@ -5,10 +5,8 @@ import { AccountOperationsPageError } from "./components/AccountOperationsPageEr
|
||||
import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view";
|
||||
import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view";
|
||||
|
||||
type AccountOperationsPageProps = { accountId?: string };
|
||||
|
||||
const AccountOperationsPage: FC<AccountOperationsPageProps> = (props) => {
|
||||
const viewModel = useAccountOperationsPageVm(props.accountId);
|
||||
const AccountOperationsPage: FC = () => {
|
||||
const viewModel = useAccountOperationsPageVm();
|
||||
|
||||
if (viewModel.state === "loading") {
|
||||
return <AccountOperationsPageLoading />;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
brokerageOperationsQueryOptions,
|
||||
operationCategories,
|
||||
type OperationCategory,
|
||||
useBrokerageOperations,
|
||||
useBrokerageOperationHistory,
|
||||
} from "@/entities/broker-operation";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||
@@ -41,11 +41,8 @@ type AccountOperationsPageViewModel
|
||||
| { onReset: () => void; retry: () => void; state: "error" }
|
||||
| { state: "loading" };
|
||||
|
||||
const useAccountOperationsPageVm = (
|
||||
scopeId?: string,
|
||||
): AccountOperationsPageViewModel => {
|
||||
const { accountId: routeAccountId } = useParams();
|
||||
const accountId = scopeId ?? routeAccountId;
|
||||
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||
const { accountId } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParameters, setSearchParameters] = useSearchParams();
|
||||
const urlFrom = searchParameters.get("from");
|
||||
@@ -74,25 +71,6 @@ const useAccountOperationsPageVm = (
|
||||
const [draftCategories, setDraftCategories]
|
||||
= useState<OperationCategory[]>(appliedCategories);
|
||||
|
||||
const scopeKey = JSON.stringify([
|
||||
accountId,
|
||||
urlFrom,
|
||||
urlTo,
|
||||
appliedCategories,
|
||||
]);
|
||||
|
||||
const [pagination, setPagination] = useState<{
|
||||
cursors: string[];
|
||||
scopeKey: string;
|
||||
}>({ cursors: [], scopeKey });
|
||||
|
||||
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
|
||||
const cursor = cursors.at(-1);
|
||||
|
||||
if (pagination.scopeKey !== scopeKey) {
|
||||
setPagination({ cursors: [], scopeKey });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isValidUrlPeriod) return;
|
||||
const normalized = new URLSearchParams(searchParameters);
|
||||
@@ -100,13 +78,6 @@ const useAccountOperationsPageVm = (
|
||||
normalized.delete("to");
|
||||
setSearchParameters(normalized, { replace: true });
|
||||
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
|
||||
const [previousSearch, setPreviousSearch] = useState(searchParameters);
|
||||
|
||||
if (previousSearch !== searchParameters) {
|
||||
setPreviousSearch(searchParameters);
|
||||
setDraftPeriod([appliedFrom, appliedTo]);
|
||||
setDraftCategories(appliedCategories);
|
||||
}
|
||||
|
||||
const firstPageQuery = {
|
||||
from: dateStart(appliedFrom),
|
||||
@@ -115,10 +86,17 @@ const useAccountOperationsPageVm = (
|
||||
limit: 100,
|
||||
};
|
||||
|
||||
const operations = useBrokerageOperations(accountId, {
|
||||
...firstPageQuery,
|
||||
...(cursor && { cursor }),
|
||||
});
|
||||
const history = useBrokerageOperationHistory(accountId, firstPageQuery);
|
||||
const { operations } = history;
|
||||
const scope = JSON.stringify([accountId, searchParameters.toString()]);
|
||||
const [previousScope, setPreviousScope] = useState(scope);
|
||||
|
||||
if (previousScope !== scope) {
|
||||
setPreviousScope(scope);
|
||||
setDraftPeriod([appliedFrom, appliedTo]);
|
||||
setDraftCategories(appliedCategories);
|
||||
history.reset();
|
||||
}
|
||||
|
||||
const sync = useScreenSync(
|
||||
{ accountId, screen: "operations", ...firstPageQuery },
|
||||
@@ -131,30 +109,13 @@ const useAccountOperationsPageVm = (
|
||||
limit: 100,
|
||||
}).queryKey;
|
||||
|
||||
const resetCursors = () => setPagination({ cursors: [], scopeKey });
|
||||
|
||||
const retry = () => {
|
||||
if (
|
||||
isApiErrorCode(
|
||||
apiErrorCode(operations.error),
|
||||
ApiErrorCode.InvalidOperationsCursor,
|
||||
)
|
||||
) {
|
||||
void queryClient.invalidateQueries({
|
||||
exact: true,
|
||||
queryKey: brokerageOperationsQueryOptions(accountId, firstPageQuery)
|
||||
.queryKey,
|
||||
});
|
||||
resetCursors();
|
||||
}
|
||||
else void sync.retry();
|
||||
};
|
||||
const retry = () => history.retry(sync.retry);
|
||||
|
||||
const onReset = () => {
|
||||
setDraftPeriod([null, null]);
|
||||
setDraftCategories([]);
|
||||
setSearchParameters({});
|
||||
resetCursors();
|
||||
history.reset();
|
||||
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
|
||||
};
|
||||
|
||||
@@ -180,7 +141,7 @@ const useAccountOperationsPageVm = (
|
||||
draftCategories,
|
||||
draftPeriod,
|
||||
hasNext: Boolean(nextCursor && operations.data.hasNext),
|
||||
hasPrevious: cursors.length > 0,
|
||||
hasPrevious: history.hasPrevious,
|
||||
historyLoading:
|
||||
operations.meta?.coverage?.historyState === "loading"
|
||||
|| operations.meta?.coverage?.historyState === "partial",
|
||||
@@ -195,20 +156,16 @@ const useAccountOperationsPageVm = (
|
||||
categories: draftCategories.join(","),
|
||||
}),
|
||||
});
|
||||
resetCursors();
|
||||
history.reset();
|
||||
},
|
||||
onCategoriesChange: values => setDraftCategories(
|
||||
values.filter((value): value is OperationCategory =>
|
||||
operationCategorySet.has(value),
|
||||
),
|
||||
),
|
||||
onNext: () => {
|
||||
if (nextCursor)
|
||||
setPagination({ cursors: [...cursors, nextCursor], scopeKey });
|
||||
},
|
||||
onNext: history.onNext,
|
||||
onPeriodChange: setDraftPeriod,
|
||||
onPrevious: () =>
|
||||
setPagination({ cursors: cursors.slice(0, -1), scopeKey }),
|
||||
onPrevious: history.onPrevious,
|
||||
onReset,
|
||||
partial: operations.meta?.availability !== "complete",
|
||||
refreshError: operations.isError || sync.sourceError || sync.transportError,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { formatShare, type Portfolio } from "@/entities/portfolio";
|
||||
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
type AccountPagePosition = Portfolio["positions"][number] & {
|
||||
instrumentTo?: string;
|
||||
logoSrc: null | string;
|
||||
returnText: null | string;
|
||||
returnTone?: AccountPageReturnTone;
|
||||
shareText: null | string;
|
||||
};
|
||||
|
||||
type AccountPagePositionGroup = {
|
||||
key: string;
|
||||
positions: AccountPagePosition[];
|
||||
};
|
||||
|
||||
type AccountPagePositionSection = {
|
||||
groups: AccountPagePositionGroup[];
|
||||
kind: PositionSectionKind;
|
||||
};
|
||||
|
||||
type AccountPageReturnTone = "negative" | "positive";
|
||||
|
||||
type PositionSectionKind = "bonds"
|
||||
| "other"
|
||||
| "stocks";
|
||||
|
||||
function groupPositions(
|
||||
positions: AccountPagePosition[],
|
||||
): AccountPagePositionGroup[] {
|
||||
const groups = new Map<string, AccountPagePositionGroup>();
|
||||
|
||||
for (const [index, position] of positions.entries()) {
|
||||
const identity = positionIdentity(position);
|
||||
const rowKey = JSON.stringify(["row", position.account?.id, index]);
|
||||
|
||||
const key
|
||||
= identity && position.account
|
||||
? JSON.stringify([position.type, ...identity])
|
||||
: rowKey;
|
||||
|
||||
const group = groups.get(key);
|
||||
|
||||
if (
|
||||
group
|
||||
&& group.positions.every(
|
||||
item => item.account?.id !== position.account?.id,
|
||||
)
|
||||
) {
|
||||
group.positions.push(position);
|
||||
}
|
||||
else {
|
||||
const uniqueKey = group ? rowKey : key;
|
||||
groups.set(uniqueKey, { key: uniqueKey, positions: [position] });
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline.
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
function positionIdentity(position: AccountPagePosition) {
|
||||
if (position.instrumentUid?.trim())
|
||||
return ["uid", position.instrumentUid.trim()];
|
||||
if (position.figi?.trim()) return ["figi", position.figi.trim()];
|
||||
|
||||
return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined;
|
||||
}
|
||||
|
||||
function toAccountPagePositionSections(
|
||||
portfolio: Portfolio,
|
||||
instrumentDetails: Map<string, undefined
|
||||
| { slug: string }> = new Map(),
|
||||
): AccountPagePositionSection[] {
|
||||
const denominators = new Map<string, number>();
|
||||
|
||||
for (const position of portfolio.positions) {
|
||||
if (!position.currentValue) continue;
|
||||
const currency = position.currentValue.currency;
|
||||
denominators.set(
|
||||
currency,
|
||||
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
|
||||
);
|
||||
}
|
||||
|
||||
const stocks = toSection(
|
||||
"stocks",
|
||||
portfolio,
|
||||
denominators,
|
||||
instrumentDetails,
|
||||
);
|
||||
|
||||
const bonds = toSection("bonds", portfolio, denominators, instrumentDetails);
|
||||
const other = toSection("other", portfolio, denominators, instrumentDetails);
|
||||
|
||||
return [stocks, bonds, other].filter(section => section.groups.length > 0);
|
||||
}
|
||||
|
||||
function toPosition(
|
||||
position: Portfolio["positions"][number],
|
||||
denominators: Map<string, number>,
|
||||
kind: PositionSectionKind,
|
||||
instrumentSlug?: string,
|
||||
): AccountPagePosition {
|
||||
const isExpanded = kind !== "other";
|
||||
|
||||
const returnValue = position.absoluteReturn
|
||||
? Number(position.absoluteReturn.value)
|
||||
: null;
|
||||
|
||||
let returnTone: AccountPageReturnTone | undefined;
|
||||
if (returnValue !== null && returnValue !== 0)
|
||||
returnTone = returnValue > 0 ? "positive" : "negative";
|
||||
|
||||
const returnPrefix
|
||||
= isExpanded && returnValue !== null && returnValue > 0 ? "+" : "";
|
||||
|
||||
return {
|
||||
...position,
|
||||
instrumentTo:
|
||||
(kind === "stocks" || kind === "bonds") && position.instrumentUid
|
||||
? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}`
|
||||
: undefined,
|
||||
logoSrc: isExpanded
|
||||
? instrumentLogoUrl(position.logoName)
|
||||
: null,
|
||||
returnText: position.absoluteReturn
|
||||
? `${returnPrefix}${formatMoney(position.absoluteReturn)}`
|
||||
: null,
|
||||
returnTone,
|
||||
shareText: position.currentValue?.currency
|
||||
? formatShare(position.currentValue, {
|
||||
currency: position.currentValue.currency,
|
||||
value: String(denominators.get(position.currentValue.currency) ?? 0),
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function toSection(
|
||||
kind: PositionSectionKind,
|
||||
portfolio: Portfolio,
|
||||
denominators: Map<string, number>,
|
||||
instrumentDetails: Map<string, undefined
|
||||
| { slug: string }>,
|
||||
): AccountPagePositionSection {
|
||||
return {
|
||||
groups: groupPositions(
|
||||
portfolio.positions
|
||||
.filter((position) => {
|
||||
if (kind === "stocks") return position.type === "STOCK";
|
||||
|
||||
return kind === "bonds"
|
||||
? position.type === "BOND"
|
||||
: position.type !== "STOCK"
|
||||
&& position.type !== "BOND"
|
||||
&& position.type !== "CURRENCY";
|
||||
})
|
||||
.map(position =>
|
||||
toPosition(
|
||||
position,
|
||||
denominators,
|
||||
kind,
|
||||
instrumentDetails.get(position.instrumentUid ?? "")?.slug,
|
||||
),
|
||||
),
|
||||
),
|
||||
kind,
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
type AccountPagePosition,
|
||||
type AccountPagePositionGroup,
|
||||
type AccountPagePositionSection,
|
||||
toAccountPagePositionSections,
|
||||
};
|
||||
@@ -24,8 +24,8 @@ import type {
|
||||
AccountPageRecentOperations,
|
||||
} from "./AccountPage.vm";
|
||||
|
||||
import { toAccountPagePositionSections } from "../../model/positions";
|
||||
import { AccountPage } from "./AccountPage";
|
||||
import { toAccountPagePositionSections } from "./AccountPage.vm";
|
||||
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
|
||||
import { AccountPageError } from "./components/AccountPageError.view";
|
||||
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
||||
@@ -39,13 +39,13 @@ function configureTestAuth() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderAccountRoute(queryClient: QueryClient) {
|
||||
function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
|
||||
configureTestAuth();
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={["/accounts/account-1"]}>
|
||||
<MemoryRouter initialEntries={[`/accounts/${accountId}`]}>
|
||||
<Routes>
|
||||
<Route element={<AccountPage />} path="/accounts/:accountId" />
|
||||
</Routes>
|
||||
@@ -202,7 +202,7 @@ describe("AccountPage states", () => {
|
||||
}),
|
||||
);
|
||||
configureTestAuth();
|
||||
renderPage(<AccountPage accountId={accountId} />);
|
||||
renderAccountRoute(createTestQueryClient(), accountId);
|
||||
await waitFor(() => {
|
||||
const links = screen.getAllByRole("link", {
|
||||
name: position.displayName,
|
||||
|
||||
@@ -6,10 +6,8 @@ import { AccountPageError } from "./components/AccountPageError.view";
|
||||
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
||||
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
|
||||
|
||||
type AccountPageProps = { accountId?: string };
|
||||
|
||||
const AccountPage: FC<AccountPageProps> = (props) => {
|
||||
const viewModel = useAccountPageViewModel(props.accountId);
|
||||
const AccountPage: FC = () => {
|
||||
const viewModel = useAccountPageViewModel();
|
||||
if (viewModel.state === "loading") return <AccountPageLoading />;
|
||||
if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
|
||||
if (viewModel.state === "empty")
|
||||
|
||||
@@ -13,10 +13,8 @@ import { createQueryWrapper } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
|
||||
import {
|
||||
toAccountPagePositionSections,
|
||||
useAccountPageViewModel,
|
||||
} from "./AccountPage.vm";
|
||||
import { toAccountPagePositionSections } from "../../model/positions";
|
||||
import { useAccountPageViewModel } from "./AccountPage.vm";
|
||||
|
||||
const portfolio: Portfolio
|
||||
= {
|
||||
|
||||
@@ -13,11 +13,12 @@ import {
|
||||
useBrokerageEvents,
|
||||
} from "@/entities/brokerage-event";
|
||||
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
|
||||
import { formatShare, usePortfolio } from "@/entities/portfolio";
|
||||
import { usePortfolio } from "@/entities/portfolio";
|
||||
import { useStocks } from "@/entities/stock";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
import { type AccountPagePositionSection, toAccountPagePositionSections } from "../../model/positions";
|
||||
|
||||
const RECENT_EVENTS_LIMIT = 5;
|
||||
|
||||
@@ -58,24 +59,6 @@ type AccountPageLoadedViewModel = AccountPageContent & {
|
||||
state: "loaded";
|
||||
};
|
||||
|
||||
type AccountPagePosition = Portfolio["positions"][number] & {
|
||||
instrumentTo?: string;
|
||||
logoSrc: null | string;
|
||||
returnText: null | string;
|
||||
returnTone?: AccountPageReturnTone;
|
||||
shareText: null | string;
|
||||
};
|
||||
|
||||
type AccountPagePositionGroup = {
|
||||
key: string;
|
||||
positions: AccountPagePosition[];
|
||||
};
|
||||
|
||||
type AccountPagePositionSection = {
|
||||
groups: AccountPagePositionGroup[];
|
||||
kind: PositionSectionKind;
|
||||
};
|
||||
|
||||
type AccountPageRecentEvents = BrokerageEventsPreviewState;
|
||||
|
||||
type AccountPageRecentOperations
|
||||
@@ -94,89 +77,12 @@ type AccountPageRecentOperations
|
||||
to: string;
|
||||
};
|
||||
|
||||
type AccountPageReturnTone = "negative" | "positive";
|
||||
|
||||
type AccountPageViewModel
|
||||
= | AccountPageEmptyViewModel
|
||||
| AccountPageLoadedViewModel
|
||||
| { code?: string; retry: () => Promise<unknown> | undefined; state: "error" }
|
||||
| { state: "loading" };
|
||||
|
||||
type PositionSectionKind = "bonds"
|
||||
| "other"
|
||||
| "stocks";
|
||||
|
||||
function groupPositions(
|
||||
positions: AccountPagePosition[],
|
||||
): AccountPagePositionGroup[] {
|
||||
const groups = new Map<string, AccountPagePositionGroup>();
|
||||
|
||||
for (const [index, position] of positions.entries()) {
|
||||
const identity = positionIdentity(position);
|
||||
const rowKey = JSON.stringify(["row", position.account?.id, index]);
|
||||
|
||||
const key
|
||||
= identity && position.account
|
||||
? JSON.stringify([position.type, ...identity])
|
||||
: rowKey;
|
||||
|
||||
const group = groups.get(key);
|
||||
|
||||
if (
|
||||
group
|
||||
&& group.positions.every(
|
||||
item => item.account?.id !== position.account?.id,
|
||||
)
|
||||
) {
|
||||
group.positions.push(position);
|
||||
}
|
||||
else {
|
||||
const uniqueKey = group ? rowKey : key;
|
||||
groups.set(uniqueKey, { key: uniqueKey, positions: [position] });
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline.
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
function positionIdentity(position: AccountPagePosition) {
|
||||
if (position.instrumentUid?.trim())
|
||||
return ["uid", position.instrumentUid.trim()];
|
||||
if (position.figi?.trim()) return ["figi", position.figi.trim()];
|
||||
|
||||
return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined;
|
||||
}
|
||||
|
||||
function toAccountPagePositionSections(
|
||||
portfolio: Portfolio,
|
||||
instrumentDetails: Map<string, undefined
|
||||
| { slug: string }> = new Map(),
|
||||
): AccountPagePositionSection[] {
|
||||
const denominators = new Map<string, number>();
|
||||
|
||||
for (const position of portfolio.positions) {
|
||||
if (!position.currentValue) continue;
|
||||
const currency = position.currentValue.currency;
|
||||
denominators.set(
|
||||
currency,
|
||||
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
|
||||
);
|
||||
}
|
||||
|
||||
const stocks = toSection(
|
||||
"stocks",
|
||||
portfolio,
|
||||
denominators,
|
||||
instrumentDetails,
|
||||
);
|
||||
|
||||
const bonds = toSection("bonds", portfolio, denominators, instrumentDetails);
|
||||
const other = toSection("other", portfolio, denominators, instrumentDetails);
|
||||
|
||||
return [stocks, bonds, other].filter(section => section.groups.length > 0);
|
||||
}
|
||||
|
||||
function toIncomeSummary(
|
||||
accountId: string | undefined,
|
||||
income: ReturnType<typeof useIncomeSummary>,
|
||||
@@ -199,47 +105,6 @@ function toIncomeSummary(
|
||||
};
|
||||
}
|
||||
|
||||
function toPosition(
|
||||
position: Portfolio["positions"][number],
|
||||
denominators: Map<string, number>,
|
||||
kind: PositionSectionKind,
|
||||
instrumentSlug?: string,
|
||||
): AccountPagePosition {
|
||||
const isExpanded = kind !== "other";
|
||||
|
||||
const returnValue = position.absoluteReturn
|
||||
? Number(position.absoluteReturn.value)
|
||||
: null;
|
||||
|
||||
let returnTone: AccountPageReturnTone | undefined;
|
||||
if (returnValue !== null && returnValue !== 0)
|
||||
returnTone = returnValue > 0 ? "positive" : "negative";
|
||||
|
||||
const returnPrefix
|
||||
= isExpanded && returnValue !== null && returnValue > 0 ? "+" : "";
|
||||
|
||||
return {
|
||||
...position,
|
||||
instrumentTo:
|
||||
(kind === "stocks" || kind === "bonds") && position.instrumentUid
|
||||
? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}`
|
||||
: undefined,
|
||||
logoSrc: isExpanded
|
||||
? instrumentLogoUrl(position.logoName)
|
||||
: null,
|
||||
returnText: position.absoluteReturn
|
||||
? `${returnPrefix}${formatMoney(position.absoluteReturn)}`
|
||||
: null,
|
||||
returnTone,
|
||||
shareText: position.currentValue?.currency
|
||||
? formatShare(position.currentValue, {
|
||||
currency: position.currentValue.currency,
|
||||
value: String(denominators.get(position.currentValue.currency) ?? 0),
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function toRecentEvents(
|
||||
accountId: string | undefined,
|
||||
events: ReturnType<typeof useBrokerageEvents>,
|
||||
@@ -299,41 +164,8 @@ function toRecentOperations(
|
||||
};
|
||||
}
|
||||
|
||||
function toSection(
|
||||
kind: PositionSectionKind,
|
||||
portfolio: Portfolio,
|
||||
denominators: Map<string, number>,
|
||||
instrumentDetails: Map<string, undefined
|
||||
| { slug: string }>,
|
||||
): AccountPagePositionSection {
|
||||
return {
|
||||
groups: groupPositions(
|
||||
portfolio.positions
|
||||
.filter((position) => {
|
||||
if (kind === "stocks") return position.type === "STOCK";
|
||||
|
||||
return kind === "bonds"
|
||||
? position.type === "BOND"
|
||||
: position.type !== "STOCK"
|
||||
&& position.type !== "BOND"
|
||||
&& position.type !== "CURRENCY";
|
||||
})
|
||||
.map(position =>
|
||||
toPosition(
|
||||
position,
|
||||
denominators,
|
||||
kind,
|
||||
instrumentDetails.get(position.instrumentUid ?? "")?.slug,
|
||||
),
|
||||
),
|
||||
),
|
||||
kind,
|
||||
};
|
||||
}
|
||||
|
||||
function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
||||
const { accountId: routeAccountId } = useParams();
|
||||
const accountId = scopeId ?? routeAccountId;
|
||||
function useAccountPageViewModel(): AccountPageViewModel {
|
||||
const { accountId } = useParams();
|
||||
const portfolio = usePortfolio(accountId);
|
||||
|
||||
const stocks = useStocks(
|
||||
@@ -344,8 +176,6 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
const instrumentDetails = stocks;
|
||||
|
||||
const operations = useBrokerageOperations(accountId, {
|
||||
limit: 5,
|
||||
});
|
||||
@@ -408,7 +238,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
||||
portfolio: portfolio.data,
|
||||
positionSections: toAccountPagePositionSections(
|
||||
portfolio.data,
|
||||
instrumentDetails,
|
||||
stocks,
|
||||
),
|
||||
recentEvents,
|
||||
recentOperations,
|
||||
@@ -419,11 +249,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
||||
|
||||
export {
|
||||
type AccountPageIncomeSummary,
|
||||
type AccountPagePosition,
|
||||
type AccountPagePositionGroup,
|
||||
type AccountPagePositionSection,
|
||||
type AccountPageRecentEvents,
|
||||
type AccountPageRecentOperations,
|
||||
toAccountPagePositionSections,
|
||||
useAccountPageViewModel,
|
||||
};
|
||||
|
||||
@@ -9,12 +9,8 @@ import type { Portfolio } from "@/entities/portfolio";
|
||||
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
|
||||
import type {
|
||||
AccountPageIncomeSummary,
|
||||
AccountPagePositionSection,
|
||||
AccountPageRecentEvents,
|
||||
AccountPageRecentOperations,
|
||||
} from "../AccountPage.vm";
|
||||
import type { AccountPagePositionSection } from "../../../model/positions";
|
||||
import type { AccountPageIncomeSummary, AccountPageRecentEvents, AccountPageRecentOperations } from "../AccountPage.vm";
|
||||
|
||||
import classes from "../AccountPage.module.css";
|
||||
import { AccountHeading } from "./AccountHeading.view";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
|
||||
import type { AccountPagePosition } from "../../AccountPage.vm";
|
||||
import type { AccountPagePosition } from "../../../../model/positions";
|
||||
|
||||
import { PositionCard } from "./PositionCard.view";
|
||||
|
||||
|
||||
@@ -6,10 +6,7 @@ import { Link } from "react-router";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
import type {
|
||||
AccountPagePosition,
|
||||
AccountPagePositionSection,
|
||||
} from "../../AccountPage.vm";
|
||||
import type { AccountPagePosition, AccountPagePositionSection } from "../../../../model/positions";
|
||||
|
||||
import classes from "../../AccountPage.module.css";
|
||||
import { PositionLogo } from "../PositionLogo.view";
|
||||
|
||||
@@ -6,10 +6,7 @@ import { Link } from "react-router";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
import type {
|
||||
AccountPagePositionGroup,
|
||||
AccountPagePositionSection,
|
||||
} from "../AccountPage.vm";
|
||||
import type { AccountPagePositionGroup, AccountPagePositionSection } from "../../../model/positions";
|
||||
|
||||
import classes from "../AccountPage.module.css";
|
||||
import { PositionLogo } from "./PositionLogo.view";
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Anchor, Avatar } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { useImageFallback } from "@/shared/lib";
|
||||
|
||||
type PositionLogoProps = {
|
||||
label: string;
|
||||
src: null | string;
|
||||
@@ -13,7 +12,9 @@ type PositionLogoProps = {
|
||||
|
||||
const PositionLogo: FC<PositionLogoProps> = (props) => {
|
||||
const { label, src } = props;
|
||||
const { failed, onError } = useImageFallback();
|
||||
const [failed, setFailed] = useState(false);
|
||||
// eslint-disable-next-line frontend/readability -- Rendering fallback belongs to this keyed logo, without page data or actions.
|
||||
const onError = () => setFailed(true);
|
||||
|
||||
const logo
|
||||
= src && !failed
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FC } from "react";
|
||||
|
||||
import { SimpleGrid } from "@mantine/core";
|
||||
|
||||
import type { AccountPagePositionSection } from "../AccountPage.vm";
|
||||
import type { AccountPagePositionSection } from "../../../model/positions";
|
||||
|
||||
import { PositionCard } from "./PositionCard/PositionCard.view";
|
||||
import { PositionGroupCard } from "./PositionGroupCard.view";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
import { toAccountPagePositionSections } from "./AccountPage.vm";
|
||||
import { toAccountPagePositionSections } from "../../model/positions";
|
||||
import { PositionSection } from "./components/PositionSection.view";
|
||||
|
||||
const position: Portfolio["positions"][number]
|
||||
|
||||
@@ -8,10 +8,7 @@ import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
AccountPagePosition,
|
||||
AccountPagePositionSection,
|
||||
} from "./AccountPage.vm";
|
||||
import type { AccountPagePosition, AccountPagePositionSection } from "../../model/positions";
|
||||
|
||||
import { PositionSection } from "./components/PositionSection.view";
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Anchor,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
@@ -21,7 +19,6 @@ import {
|
||||
InstrumentChart,
|
||||
InstrumentEvents,
|
||||
InstrumentPosition,
|
||||
InstrumentSection,
|
||||
} from "@/features/instrument-details";
|
||||
import {
|
||||
formatDate,
|
||||
@@ -33,6 +30,9 @@ import {
|
||||
import type { BondPageVm } from "./BondPage.vm";
|
||||
|
||||
import classes from "./BondPage.module.css";
|
||||
import { BondCoupons } from "./components/BondCoupons.view";
|
||||
import { BondMetrics } from "./components/BondMetrics.view";
|
||||
import { Metric } from "./components/Metric.view";
|
||||
|
||||
type BondPageViewProps = { vm: BondPageVm };
|
||||
|
||||
@@ -226,7 +226,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
</div>
|
||||
</Paper>
|
||||
|
||||
<BondMetrics vm={vm} />
|
||||
<BondMetrics metrics={vm.metrics} state={vm.metricsState} />
|
||||
|
||||
<InstrumentChart priceUnit="percent" vm={vm} />
|
||||
|
||||
@@ -236,7 +236,12 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
vm={vm}
|
||||
/>
|
||||
|
||||
<BondCoupons vm={vm} />
|
||||
<BondCoupons
|
||||
coupons={vm.coupons}
|
||||
pastCoupons={vm.pastCoupons}
|
||||
state={vm.couponsState}
|
||||
upcoming={vm.upcoming}
|
||||
/>
|
||||
|
||||
<InstrumentEvents
|
||||
historyNote={t("bond.historyNote")}
|
||||
@@ -248,283 +253,4 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const BondMetrics: FC<BondPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
const creditRating = vm.metrics?.creditRating;
|
||||
|
||||
let rating = t(
|
||||
vm.metrics?.unavailable.includes("rating")
|
||||
? "bond.ratingUnavailable"
|
||||
: "bond.ratingMissing",
|
||||
);
|
||||
|
||||
if (creditRating) {
|
||||
rating
|
||||
= creditRating.status === "withdrawn"
|
||||
? t("bond.ratingWithdrawn")
|
||||
: (creditRating.value ?? t("bond.unknown"));
|
||||
}
|
||||
|
||||
const couponDate = vm.metrics?.couponDate
|
||||
? formatDate(vm.metrics?.couponDate)
|
||||
: t("bond.unknown");
|
||||
|
||||
const effectiveYield = vm.metrics?.effectiveYield
|
||||
? `${formatDecimal(vm.metrics?.effectiveYield.value)} %`
|
||||
: t("bond.unknown");
|
||||
|
||||
const couponYield
|
||||
= vm.metrics?.couponYieldPercent == undefined
|
||||
? t("bond.unknown")
|
||||
: `${formatDecimal(vm.metrics?.couponYieldPercent)} %`;
|
||||
|
||||
let yieldContent;
|
||||
|
||||
if (vm.metrics?.effectiveYield) {
|
||||
yieldContent = (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.yieldSource", {
|
||||
at: formatSnapshotTime(vm.metrics?.effectiveYield.tradeAt),
|
||||
date: formatDate(vm.metrics?.effectiveYield.date),
|
||||
price: formatDecimal(
|
||||
vm.metrics?.effectiveYield.pricePercent,
|
||||
),
|
||||
target: t(
|
||||
`bond.yieldTargets.${vm.metrics?.effectiveYield.dateType}`,
|
||||
{ defaultValue: t("bond.yieldTargets.unknown") },
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
else {
|
||||
yieldContent = (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.yieldUnavailable")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InstrumentSection state={vm.metricsState} title={t("bond.metrics")}>
|
||||
{!!vm.metrics && (
|
||||
<Stack gap="md">
|
||||
<div className={classes.companyMetrics}>
|
||||
<Metric label={t("bond.creditRating")} value={rating} />
|
||||
|
||||
<Metric
|
||||
label={t("bond.couponAmount")}
|
||||
value={formatMoney(vm.metrics.coupon)}
|
||||
/>
|
||||
|
||||
<Metric
|
||||
label={t("bond.couponType")}
|
||||
value={t(`bond.couponTypes.${vm.metrics.couponType}`, {
|
||||
defaultValue: t("bond.unknown"),
|
||||
})}
|
||||
/>
|
||||
|
||||
<Metric label={t("bond.couponDate")} value={couponDate} />
|
||||
|
||||
<Metric label={t("bond.effectiveYield")} value={effectiveYield} />
|
||||
|
||||
<Metric label={t("bond.couponYield")} value={couponYield} />
|
||||
</div>
|
||||
|
||||
{!!vm.metrics.creditRating && (
|
||||
<Group gap="xs">
|
||||
<Anchor
|
||||
href={vm.metrics.creditRating.sourceUrl}
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
target="_blank"
|
||||
>
|
||||
{t("bond.ratingSource", {
|
||||
date: formatDate(vm.metrics.creditRating.date),
|
||||
})}
|
||||
</Anchor>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.ratingChecked", {
|
||||
date: formatSnapshotTime(vm.metrics.creditRating.observedAt),
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.ratingCoverage")}
|
||||
</Text>
|
||||
|
||||
{yieldContent}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponYieldNote")}
|
||||
</Text>
|
||||
|
||||
{!!vm.metrics?.couponYieldPriceAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponYieldPriceAt", {
|
||||
date: formatSnapshotTime(vm.metrics?.couponYieldPriceAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{vm.metrics.unavailable.length > 0 && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.metricsPartial")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</InstrumentSection>
|
||||
);
|
||||
};
|
||||
|
||||
const BondCoupons: FC<BondPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<InstrumentSection
|
||||
state={vm.couponsState}
|
||||
title={t("bond.upcomingCoupons")}
|
||||
>
|
||||
{!!vm.coupons && (
|
||||
<Stack gap="md">
|
||||
{!!(vm.coupons.estimateUnavailable || vm.coupons.positionIsStale) && (
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
{t("stock.estimateUnavailable")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{vm.upcoming.length > 0
|
||||
? (
|
||||
vm.upcoming.map(coupon => (
|
||||
<Coupon
|
||||
coupon={coupon}
|
||||
key={`${coupon.number}:${coupon.paymentDate}`}
|
||||
/>
|
||||
))
|
||||
)
|
||||
: <Text c="dimmed">{t("bond.noCoupons")}</Text>}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponWindow", {
|
||||
from: formatDate(vm.coupons.from),
|
||||
to: formatDate(vm.coupons.to),
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponNote")}
|
||||
</Text>
|
||||
|
||||
{!!vm.coupons.positionAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.snapshotAt", {
|
||||
date: formatSnapshotTime(vm.coupons.positionAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Accordion radius="md" variant="contained">
|
||||
<Accordion.Item value="history">
|
||||
<Accordion.Control>{t("bond.couponHistory")}</Accordion.Control>
|
||||
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
{vm.pastCoupons.length > 0
|
||||
? (
|
||||
vm.pastCoupons.map(coupon => (
|
||||
<Coupon
|
||||
coupon={coupon}
|
||||
key={`${coupon.number}:${coupon.paymentDate}`}
|
||||
/>
|
||||
))
|
||||
)
|
||||
: <Text c="dimmed">{t("bond.noCoupons")}</Text>}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
)}
|
||||
</InstrumentSection>
|
||||
);
|
||||
};
|
||||
|
||||
type MetricProps = { label: string; value: string };
|
||||
|
||||
const Metric: FC<MetricProps> = props => (
|
||||
<div>
|
||||
<Text c="dimmed" size="xs">
|
||||
{props.label}
|
||||
</Text>
|
||||
|
||||
<Text fw={600} size="lg">
|
||||
{props.value}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
type CouponProps = {
|
||||
coupon: NonNullable<BondPageVm["coupons"]>["coupons"][number];
|
||||
};
|
||||
|
||||
const Coupon: FC<CouponProps> = (props) => {
|
||||
const { coupon } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Paper p="md" radius="md" withBorder>
|
||||
<Group align="start" justify="space-between">
|
||||
<div>
|
||||
<Text fw={600}>
|
||||
{coupon.paymentDate
|
||||
? formatDate(coupon.paymentDate)
|
||||
: t("stock.dateUnknown")}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponNumber", {
|
||||
number: coupon.number ?? t("bond.unknown"),
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Metric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
|
||||
|
||||
{!!coupon.estimatedAmount && (
|
||||
<Metric
|
||||
label={t("stock.yourEstimate")}
|
||||
value={formatMoney(coupon.estimatedAmount)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap="md" mt="sm">
|
||||
<Badge color="gray" variant="light">
|
||||
{t(`bond.couponTypes.${coupon.couponType}`, {
|
||||
defaultValue: t("bond.unknown"),
|
||||
})}
|
||||
</Badge>
|
||||
|
||||
{coupon.periodDays != undefined && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponDays", { count: coupon.periodDays })}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!coupon.recordDate && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.record")}: {formatDate(coupon.recordDate)}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { BondPageView };
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
|
||||
import type { OperationCategory } from "@/entities/broker-operation";
|
||||
|
||||
import {
|
||||
type BondPeriod,
|
||||
useBond,
|
||||
@@ -11,10 +9,8 @@ import {
|
||||
useBondPosition,
|
||||
useBondQuote,
|
||||
} from "@/entities/bond";
|
||||
import {
|
||||
section,
|
||||
useInstrumentOperations,
|
||||
} from "@/features/instrument-details";
|
||||
import { type OperationCategory, useBrokerageOperationHistory } from "@/entities/broker-operation";
|
||||
import { section } from "@/features/instrument-details";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { instrumentLogoUrl } from "@/shared/lib";
|
||||
@@ -51,12 +47,12 @@ function useBondPageVm() {
|
||||
const position = useBondPosition(uid);
|
||||
const coupons = useBondCoupons(uid);
|
||||
|
||||
const history = useInstrumentOperations(
|
||||
uid,
|
||||
filter,
|
||||
const history = useBrokerageOperationHistory(uid ? "all" : undefined, {
|
||||
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
|
||||
categories[filter],
|
||||
);
|
||||
categories: categories[filter],
|
||||
instrumentUid: uid,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const { operations } = history;
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Accordion, Alert, Badge, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BondCoupons as Coupons } from "@/entities/bond";
|
||||
|
||||
import { InstrumentSection, type SectionState } from "@/features/instrument-details";
|
||||
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
|
||||
import { Metric } from "./Metric.view";
|
||||
|
||||
type BondCouponsProps = {
|
||||
coupons?: Coupons;
|
||||
pastCoupons: Coupons["coupons"];
|
||||
state: SectionState;
|
||||
upcoming: Coupons["coupons"];
|
||||
};
|
||||
|
||||
const BondCoupons: FC<BondCouponsProps> = (props) => {
|
||||
const { coupons, pastCoupons, state, upcoming } = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<InstrumentSection
|
||||
state={state}
|
||||
title={t("bond.upcomingCoupons")}
|
||||
>
|
||||
{!!coupons && (
|
||||
<Stack gap="md">
|
||||
{!!(coupons.estimateUnavailable || coupons.positionIsStale) && (
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
{t("stock.estimateUnavailable")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{upcoming.length > 0
|
||||
? (
|
||||
upcoming.map(coupon => (
|
||||
<Coupon
|
||||
coupon={coupon}
|
||||
key={`${coupon.number}:${coupon.paymentDate}`}
|
||||
/>
|
||||
))
|
||||
)
|
||||
: <Text c="dimmed">{t("bond.noCoupons")}</Text>}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponWindow", {
|
||||
from: formatDate(coupons.from),
|
||||
to: formatDate(coupons.to),
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponNote")}
|
||||
</Text>
|
||||
|
||||
{!!coupons.positionAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.snapshotAt", {
|
||||
date: formatSnapshotTime(coupons.positionAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Accordion radius="md" variant="contained">
|
||||
<Accordion.Item value="history">
|
||||
<Accordion.Control>{t("bond.couponHistory")}</Accordion.Control>
|
||||
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
{pastCoupons.length > 0
|
||||
? (
|
||||
pastCoupons.map(coupon => (
|
||||
<Coupon
|
||||
coupon={coupon}
|
||||
key={`${coupon.number}:${coupon.paymentDate}`}
|
||||
/>
|
||||
))
|
||||
)
|
||||
: <Text c="dimmed">{t("bond.noCoupons")}</Text>}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
)}
|
||||
</InstrumentSection>
|
||||
);
|
||||
};
|
||||
|
||||
type CouponProps = {
|
||||
coupon: Coupons["coupons"][number];
|
||||
};
|
||||
|
||||
const Coupon: FC<CouponProps> = (props) => {
|
||||
const { coupon } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Paper p="md" radius="md" withBorder>
|
||||
<Group align="start" justify="space-between">
|
||||
<div>
|
||||
<Text fw={600}>
|
||||
{coupon.paymentDate
|
||||
? formatDate(coupon.paymentDate)
|
||||
: t("stock.dateUnknown")}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponNumber", {
|
||||
number: coupon.number ?? t("bond.unknown"),
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Metric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
|
||||
|
||||
{!!coupon.estimatedAmount && (
|
||||
<Metric
|
||||
label={t("stock.yourEstimate")}
|
||||
value={formatMoney(coupon.estimatedAmount)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap="md" mt="sm">
|
||||
<Badge color="gray" variant="light">
|
||||
{t(`bond.couponTypes.${coupon.couponType}`, {
|
||||
defaultValue: t("bond.unknown"),
|
||||
})}
|
||||
</Badge>
|
||||
|
||||
{coupon.periodDays != undefined && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponDays", { count: coupon.periodDays })}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!coupon.recordDate && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.record")}: {formatDate(coupon.recordDate)}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { BondCoupons };
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Anchor, Group, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BondMetrics as Metrics } from "@/entities/bond";
|
||||
|
||||
import { InstrumentSection, type SectionState } from "@/features/instrument-details";
|
||||
import { formatDate, formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
|
||||
import classes from "../BondPage.module.css";
|
||||
import { Metric } from "./Metric.view";
|
||||
|
||||
type BondMetricsProps = { metrics?: Metrics; state: SectionState };
|
||||
|
||||
const BondMetrics: FC<BondMetricsProps> = (props) => {
|
||||
const { metrics, state } = props;
|
||||
const { t } = useTranslation();
|
||||
const creditRating = metrics?.creditRating;
|
||||
|
||||
let rating = t(
|
||||
metrics?.unavailable.includes("rating")
|
||||
? "bond.ratingUnavailable"
|
||||
: "bond.ratingMissing",
|
||||
);
|
||||
|
||||
if (creditRating) {
|
||||
rating
|
||||
= creditRating.status === "withdrawn"
|
||||
? t("bond.ratingWithdrawn")
|
||||
: (creditRating.value ?? t("bond.unknown"));
|
||||
}
|
||||
|
||||
const couponDate = metrics?.couponDate
|
||||
? formatDate(metrics?.couponDate)
|
||||
: t("bond.unknown");
|
||||
|
||||
const effectiveYield = metrics?.effectiveYield
|
||||
? `${formatDecimal(metrics?.effectiveYield.value)} %`
|
||||
: t("bond.unknown");
|
||||
|
||||
const couponYield
|
||||
= metrics?.couponYieldPercent == undefined
|
||||
? t("bond.unknown")
|
||||
: `${formatDecimal(metrics?.couponYieldPercent)} %`;
|
||||
|
||||
let yieldContent;
|
||||
|
||||
if (metrics?.effectiveYield) {
|
||||
yieldContent = (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.yieldSource", {
|
||||
at: formatSnapshotTime(metrics?.effectiveYield.tradeAt),
|
||||
date: formatDate(metrics?.effectiveYield.date),
|
||||
price: formatDecimal(
|
||||
metrics?.effectiveYield.pricePercent,
|
||||
),
|
||||
target: t(
|
||||
`bond.yieldTargets.${metrics?.effectiveYield.dateType}`,
|
||||
{ defaultValue: t("bond.yieldTargets.unknown") },
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
else {
|
||||
yieldContent = (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.yieldUnavailable")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InstrumentSection state={state} title={t("bond.metrics")}>
|
||||
{!!metrics && (
|
||||
<Stack gap="md">
|
||||
<div className={classes.companyMetrics}>
|
||||
<Metric label={t("bond.creditRating")} value={rating} />
|
||||
|
||||
<Metric
|
||||
label={t("bond.couponAmount")}
|
||||
value={formatMoney(metrics.coupon)}
|
||||
/>
|
||||
|
||||
<Metric
|
||||
label={t("bond.couponType")}
|
||||
value={t(`bond.couponTypes.${metrics.couponType}`, {
|
||||
defaultValue: t("bond.unknown"),
|
||||
})}
|
||||
/>
|
||||
|
||||
<Metric label={t("bond.couponDate")} value={couponDate} />
|
||||
|
||||
<Metric label={t("bond.effectiveYield")} value={effectiveYield} />
|
||||
|
||||
<Metric label={t("bond.couponYield")} value={couponYield} />
|
||||
</div>
|
||||
|
||||
{!!metrics.creditRating && (
|
||||
<Group gap="xs">
|
||||
<Anchor
|
||||
href={metrics.creditRating.sourceUrl}
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
target="_blank"
|
||||
>
|
||||
{t("bond.ratingSource", {
|
||||
date: formatDate(metrics.creditRating.date),
|
||||
})}
|
||||
</Anchor>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.ratingChecked", {
|
||||
date: formatSnapshotTime(metrics.creditRating.observedAt),
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.ratingCoverage")}
|
||||
</Text>
|
||||
|
||||
{yieldContent}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponYieldNote")}
|
||||
</Text>
|
||||
|
||||
{!!metrics?.couponYieldPriceAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponYieldPriceAt", {
|
||||
date: formatSnapshotTime(metrics?.couponYieldPriceAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{metrics.unavailable.length > 0 && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.metricsPartial")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</InstrumentSection>
|
||||
);
|
||||
};
|
||||
|
||||
export { BondMetrics };
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
type MetricProps = { label: string; value: string };
|
||||
|
||||
const Metric: FC<MetricProps> = props => (
|
||||
<div>
|
||||
<Text c="dimmed" size="xs">
|
||||
{props.label}
|
||||
</Text>
|
||||
|
||||
<Text fw={600} size="lg">
|
||||
{props.value}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
export { Metric };
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
|
||||
import type { OperationCategory } from "@/entities/broker-operation";
|
||||
|
||||
import { type OperationCategory, useBrokerageOperationHistory } from "@/entities/broker-operation";
|
||||
import {
|
||||
type StockPeriod,
|
||||
useStock,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
} from "@/entities/stock";
|
||||
import {
|
||||
section,
|
||||
useInstrumentOperations,
|
||||
} from "@/features/instrument-details";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
@@ -51,12 +49,12 @@ function useStockPageVm() {
|
||||
const company = useStockCompany(uid);
|
||||
const dividends = useStockDividends(uid);
|
||||
|
||||
const history = useInstrumentOperations(
|
||||
uid,
|
||||
filter,
|
||||
const history = useBrokerageOperationHistory(uid ? "all" : undefined, {
|
||||
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
|
||||
categories[filter],
|
||||
);
|
||||
categories: categories[filter],
|
||||
instrumentUid: uid,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const { operations } = history;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user