refactor: align frontend ownership boundaries

Close audit gaps across API drift, query policy, UI, and tests.
This commit is contained in:
2026-09-06 18:20:05 +03:00
parent 175727bc93
commit c97f62bd98
118 changed files with 2190 additions and 2254 deletions
@@ -0,0 +1,48 @@
import { Stack } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { StateView } from "@/shared/ui";
import type {
AccountPageIncomeSummary,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "../AccountPage.vm";
import { AccountHeading } from "./AccountHeading.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
import { RecentEvents } from "./RecentEvents.view";
import { RecentOperations } from "./RecentOperations.view";
type AccountPageEmptyProps = {
account?: BrokerageAccount;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
};
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<AccountHeading account={props.account} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
refreshError={props.refreshError ?? false}
retry={props.retry}
/>
<StateView
title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")}
/>
<RecentOperations operations={props.recentOperations} />
<RecentEvents events={props.recentEvents} />
{props.incomeSummary ? <IncomeSummary {...props.incomeSummary} /> : null}
</Stack>
);
};
export { AccountPageEmpty };
@@ -0,0 +1,57 @@
import { Button } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { StateView } from "@/shared/ui";
type AccountPageErrorProps = { code?: string; retry: () => void };
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
const { code, retry } = props;
const { t } = useTranslation();
if (code === "NOT_FOUND")
return (
<StateView
error
title={t("portfolio.accountUnavailableTitle")}
text={t("portfolio.accountUnavailableText")}
action={
<Button component={Link} to="/accounts">
{t("portfolio.back")}
</Button>
}
/>
);
if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("portfolio.connectionErrorTitle")}
text={t("portfolio.connectionErrorText")}
action={
<Button component={Link} to="/settings">
{t("accounts.openSettings")}
</Button>
}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
};
export { AccountPageError };
@@ -0,0 +1,117 @@
import { Accordion, Alert, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio";
import { formatSnapshotTime } from "@/shared/lib";
import classes from "../AccountPage.module.css";
import type {
AccountPageIncomeSummary,
AccountPagePositionSection,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "../AccountPage.vm";
import { AccountHeading } from "./AccountHeading.view";
import { CashBalances } from "./CashBalances.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
import { PortfolioTotals } from "./PortfolioTotals.view";
import { PositionSection } from "./PositionSection.view";
import { RecentEvents } from "./RecentEvents.view";
import { RecentOperations } from "./RecentOperations.view";
type AccountPageLoadedProps = {
account?: BrokerageAccount;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
};
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
const { t } = useTranslation();
return (
<Stack className={classes.page} gap="lg">
<AccountHeading account={props.account} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
refreshError={props.refreshError ?? false}
retry={props.retry}
/>
{props.portfolio.isStale ? (
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
) : null}
<Text c="dimmed" size="sm">
{t("portfolio.updatedAt", {
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
})}
</Text>
<PortfolioTotals totals={props.portfolio.totalsByCurrency} />
<Accordion
multiple
defaultValue={[
...props.positionSections.map((section) => section.kind),
"cash",
"recentOperations",
"recentEvents",
"incomeSummary",
]}
classNames={{
control: classes.accordionControl,
label: classes.accordionLabel,
chevron: classes.accordionChevron,
panel: classes.accordionPanel,
}}
>
{props.positionSections.map((section) => (
<Accordion.Item key={section.kind} value={section.kind}>
<Accordion.Control>
{t(positionSectionTitleKey(section.kind))}
</Accordion.Control>
<Accordion.Panel>
<PositionSection section={section} />
</Accordion.Panel>
</Accordion.Item>
))}
<Accordion.Item value="cash">
<Accordion.Control>{t("portfolio.cashBalances")}</Accordion.Control>
<Accordion.Panel>
<CashBalances balances={props.portfolio.cashBalances} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentOperations">
<Accordion.Control>{t("ledger.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<RecentOperations operations={props.recentOperations} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentEvents">
<Accordion.Control>{t("events.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<RecentEvents events={props.recentEvents} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="incomeSummary">
<Accordion.Control>{t("income.summaryTitle")}</Accordion.Control>
<Accordion.Panel>
{props.incomeSummary ? (
<IncomeSummary {...props.incomeSummary} />
) : null}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
};
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
if (kind === "stocks") return "portfolio.stocks";
if (kind === "bonds") return "portfolio.bonds";
return "portfolio.otherPositions";
}
export { AccountPageLoaded };
@@ -0,0 +1,19 @@
import { Skeleton } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
const AccountPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="35%" />
<Skeleton height={120} radius="md" />
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={52} radius="sm" />
))}
</LoadingSkeleton>
);
};
export { AccountPageLoading };
@@ -1,7 +1,7 @@
import { Paper, Text } from "@mantine/core";
import type { FC } from "react";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css";
type CashBalancesProps = {
@@ -3,7 +3,7 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import { StateView } from "@/shared/ui";
type IncomeSummaryProps =
@@ -1,7 +1,7 @@
import { Paper, SimpleGrid, Text } from "@mantine/core";
import type { FC } from "react";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css";
type PortfolioTotalsProps = {
@@ -1,44 +1,28 @@
import { Card, Group, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css";
import type { AccountPagePosition } from "../AccountPage.vm";
import type {
AccountPagePosition,
AccountPagePositionSection,
} from "../AccountPage.vm";
import { PositionLogo } from "./PositionLogo.view";
type PositionCardProps = {
kind: AccountPagePositionSection["kind"];
position: AccountPagePosition;
wide?: boolean;
variant?: "stock" | "bond";
};
const PositionCard: FC<PositionCardProps> = (props) => {
const { position, wide = false, variant } = props;
const { kind, position } = props;
const { t } = useTranslation();
const isExpanded = variant === "stock" || variant === "bond";
const ec =
variant === "bond"
? {
card: classes.bondCard,
main: classes.bondMain,
identity: classes.bondIdentity,
identifiers: classes.bondIdentifiers,
value: classes.bondValue,
meta: classes.bondMeta,
}
: {
card: classes.stockCard,
main: classes.stockMain,
identity: classes.stockIdentity,
identifiers: classes.stockIdentifiers,
value: classes.stockValue,
meta: classes.stockMeta,
};
const isExpanded = kind !== "other";
const returnClass = position.returnTone
? classes[position.returnTone]
: undefined;
const returnText =
position.returnText && wide
position.returnText && isExpanded
? position.returnText
: position.returnText
? t("portfolio.return", {
@@ -46,7 +30,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
})
: null;
const identifier =
variant === "stock" ? (position.ticker ?? position.isin) : position.isin;
kind === "stocks" ? (position.ticker ?? position.isin) : position.isin;
const identifierText = [
t("portfolio.quantity", { value: position.quantity }),
identifier,
@@ -68,22 +52,22 @@ const PositionCard: FC<PositionCardProps> = (props) => {
if (isExpanded)
return (
<Card className={`${classes.tile} ${ec.card}`} withBorder>
<div className={ec.main}>
<Card className={`${classes.tile} ${classes.expandedCard}`} withBorder>
<div className={classes.expandedMain}>
<PositionLogo
key={position.logoSrc ?? "no-logo"}
src={position.logoSrc}
label={position.displayName}
/>
<Stack gap={2} miw={0} className={ec.identity}>
<Stack gap={2} miw={0} className={classes.expandedIdentity}>
<Text fw={600} lineClamp={1}>
{position.displayName}
</Text>
<Text c="dimmed" size="xs" className={ec.identifiers}>
<Text c="dimmed" size="xs" className={classes.expandedIdentifiers}>
{identifierText}
</Text>
</Stack>
<Stack gap={2} align="flex-end" className={ec.value}>
<Stack gap={2} align="flex-end" className={classes.expandedValue}>
{position.currentValue ? (
<Text fw={700} size="lg">
{formatMoney(position.currentValue)}
@@ -97,7 +81,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
</Stack>
</div>
{secondaryText ? (
<Text c="dimmed" size="xs" className={ec.meta}>
<Text c="dimmed" size="xs" className={classes.expandedMeta}>
{secondaryText}
</Text>
) : null}
@@ -106,13 +90,6 @@ const PositionCard: FC<PositionCardProps> = (props) => {
return (
<Card className={classes.tile} withBorder>
<Group justify="space-between" align="flex-start" wrap="nowrap">
{wide ? (
<PositionLogo
key={position.logoSrc ?? "no-logo"}
src={position.logoSrc}
label={position.displayName}
/>
) : null}
<Stack gap={2} miw={0}>
<Text fw={700} lineClamp={1}>
{position.displayName}
@@ -9,21 +9,15 @@ type PositionSectionProps = {
const PositionSection: FC<PositionSectionProps> = (props) => {
const { section } = props;
const expanded = section.kind !== "other";
return (
<SimpleGrid cols={section.wide ? 1 : { base: 1, sm: 2, md: 3 }}>
<SimpleGrid cols={expanded ? 1 : { base: 1, sm: 2, md: 3 }}>
{section.positions.map((position) => (
<PositionCard
kind={section.kind}
key={`${position.figi}-${position.displayName}-${position.quantity}`}
position={position}
wide={section.wide}
variant={
section.kind === "stocks"
? "stock"
: section.kind === "bonds"
? "bond"
: undefined
}
/>
))}
</SimpleGrid>
@@ -3,7 +3,7 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { BrokerageEvent } from "@/entities/brokerage-event";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui";
import classes from "../AccountPage.module.css";
import type { AccountPageRecentEvents } from "../AccountPage.vm";
-8
View File
@@ -1,8 +0,0 @@
export { AccountHeading } from "./AccountHeading.view";
export { CashBalances } from "./CashBalances.view";
export { IncomeSummary } from "./IncomeSummary.view";
export { PortfolioRefreshState } from "./PortfolioRefreshState.view";
export { PortfolioTotals } from "./PortfolioTotals.view";
export { PositionSection } from "./PositionSection.view";
export { RecentEvents } from "./RecentEvents.view";
export { RecentOperations } from "./RecentOperations.view";