feat: redesign portfolio position cards

This commit is contained in:
2026-08-17 13:35:22 +03:00
parent da55de5b50
commit 5168870cf2
9 changed files with 182 additions and 44 deletions
+1
View File
@@ -2,6 +2,7 @@ export type { Portfolio } from "./api/usePortfolio";
export { usePortfolio } from "./api/usePortfolio"; export { usePortfolio } from "./api/usePortfolio";
export { export {
formatMoney, formatMoney,
formatShare,
formatSnapshotTime, formatSnapshotTime,
portfolioErrorCode, portfolioErrorCode,
} from "./model/display"; } from "./model/display";
+24 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { formatMoney } from "./display"; import { formatMoney, formatShare } from "./display";
describe("portfolio display helpers", () => { describe("portfolio display helpers", () => {
it("formats decimal strings without financial arithmetic", () => { it("formats decimal strings without financial arithmetic", () => {
@@ -10,4 +10,27 @@ describe("portfolio display helpers", () => {
expect(formatMoney({ value: "0.5", currency: "USD" })).toBe("0,5 USD"); expect(formatMoney({ value: "0.5", currency: "USD" })).toBe("0,5 USD");
expect(formatMoney(null)).toBe("—"); expect(formatMoney(null)).toBe("—");
}); });
it("formats a share as a display-only percentage", () => {
expect(
formatShare(
{ currency: "RUB", value: "150" },
{ currency: "RUB", value: "1000" },
),
).toBe("15 %");
expect(
formatShare(
{ currency: "RUB", value: "123.45" },
{ currency: "RUB", value: "999.99" },
),
).toBe("12,3 %");
expect(formatShare(null, { currency: "RUB", value: "100" })).toBeNull();
expect(formatShare({ currency: "RUB", value: "100" }, null)).toBeNull();
expect(
formatShare(
{ currency: "RUB", value: "100" },
{ currency: "RUB", value: "0" },
),
).toBeNull();
});
}); });
+15
View File
@@ -17,6 +17,21 @@ export function formatMoney(money: Money | null): string {
return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`; return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`;
} }
export function formatShare(
numerator: Money | null,
denominator: Money | null,
): string | null {
if (!numerator || !denominator) return null;
const total = Number(denominator.value);
if (!Number.isFinite(total) || total <= 0) return null;
const percent = (Number(numerator.value) / total) * 100;
if (!Number.isFinite(percent)) return null;
const rounded = new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
}).format(percent);
return `${rounded} %`;
}
export function formatSnapshotTime(value: string): string { export function formatSnapshotTime(value: string): string {
return new Intl.DateTimeFormat("ru-RU", { return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium", dateStyle: "medium",
+12 -6
View File
@@ -3,7 +3,7 @@
} }
.total, .total,
.row, .tile,
.cash { .cash {
border-color: var(--mantine-color-gray-3); border-color: var(--mantine-color-gray-3);
background: var(--mantine-color-gray-0); background: var(--mantine-color-gray-0);
@@ -14,7 +14,7 @@
border-top: 3px solid var(--mantine-color-blue-6); border-top: 3px solid var(--mantine-color-blue-6);
} }
.row, .tile,
.cash { .cash {
padding: var(--mantine-spacing-md); padding: var(--mantine-spacing-md);
} }
@@ -24,8 +24,14 @@
justify-content: space-between; justify-content: space-between;
} }
@media (max-width: 36em) { .positive {
.row :global(.mantine-Group-root) { color: var(--mantine-color-green-6);
align-items: flex-start; }
}
.negative {
color: var(--mantine-color-red-6);
}
.mono {
font-family: var(--mantine-font-family-monospace);
} }
+14 -8
View File
@@ -36,6 +36,7 @@ const portfolio: Portfolio = {
currentValue: null, currentValue: null,
averagePurchasePrice: null, averagePurchasePrice: null,
absoluteReturn: null, absoluteReturn: null,
isin: null,
}, },
{ {
figi: "FIGI-1", figi: "FIGI-1",
@@ -43,8 +44,9 @@ const portfolio: Portfolio = {
type: "BOND", type: "BOND",
quantity: "1", quantity: "1",
currentValue: { currency: "RUB", value: "10" }, currentValue: { currency: "RUB", value: "10" },
averagePurchasePrice: null, averagePurchasePrice: { currency: "RUB", value: "11" },
absoluteReturn: { currency: "RUB", value: "-1" }, absoluteReturn: { currency: "RUB", value: "-1" },
isin: "RU000A0JX0J2",
}, },
], ],
cashBalances: [{ currency: "USD", value: "0.5" }], cashBalances: [{ currency: "USD", value: "0.5" }],
@@ -80,7 +82,7 @@ describe("AccountPage states", () => {
expect(retry).toHaveBeenCalledOnce(); expect(retry).toHaveBeenCalledOnce();
}); });
it("renders a stale portfolio in upstream order with nullable values", () => { it("renders a stale portfolio in upstream order with nullable values hidden", () => {
renderPage(<AccountPageLoaded portfolio={portfolio} />); renderPage(<AccountPageLoaded portfolio={portfolio} />);
expect( expect(
@@ -91,16 +93,20 @@ describe("AccountPage states", () => {
"T-Bank временно недоступен. Показан последний успешный снимок.", "T-Bank временно недоступен. Показан последний успешный снимок.",
), ),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("—")).toBeInTheDocument();
expect(screen.getByText("Доход: —")).toBeInTheDocument();
const positions = screen
.getAllByText(/Вторая|Первая/)
.map((element) => element.textContent);
expect(positions).toEqual(["Вторая", "Первая"]);
expect(screen.getByRole("heading", { name: "Акции" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Акции" })).toBeInTheDocument();
expect( expect(
screen.getByRole("heading", { name: "Облигации" }), screen.getByRole("heading", { name: "Облигации" }),
).toBeInTheDocument(); ).toBeInTheDocument();
const positions = screen
.getAllByText(/Вторая|Первая/)
.map((element) => element.textContent);
expect(positions).toEqual(["Вторая", "Первая"]);
expect(screen.getByText("10 ₽")).toBeInTheDocument();
expect(screen.getByText("Доход: -1 ₽")).toBeInTheDocument();
expect(screen.getByText("Средняя цена: 11 ₽")).toBeInTheDocument();
expect(screen.getByText("Доля: 100 %")).toBeInTheDocument();
expect(screen.getByText("RU000A0JX0J2")).toBeInTheDocument();
expect(screen.queryByText("—")).not.toBeInTheDocument();
}); });
it("uses the cached account title when available", () => { it("uses the cached account title when available", () => {
+107 -28
View File
@@ -19,7 +19,11 @@ import {
useBrokerageEvents, useBrokerageEvents,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import { formatMoney, formatSnapshotTime } from "@/entities/portfolio"; import {
formatMoney,
formatShare,
formatSnapshotTime,
} from "@/entities/portfolio";
import { StateView } from "@/shared/ui"; import { StateView } from "@/shared/ui";
import classes from "./AccountPage.module.css"; import classes from "./AccountPage.module.css";
@@ -124,6 +128,15 @@ export function AccountPageLoaded({
const other = portfolio.positions.filter( const other = portfolio.positions.filter(
(p) => p.type !== "STOCK" && p.type !== "BOND", (p) => p.type !== "STOCK" && p.type !== "BOND",
); );
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),
);
}
return ( return (
<Stack className={classes.page} gap="lg"> <Stack className={classes.page} gap="lg">
<AccountHeading account={account} /> <AccountHeading account={account} />
@@ -148,15 +161,24 @@ export function AccountPageLoaded({
))} ))}
</SimpleGrid> </SimpleGrid>
{stocks.length ? ( {stocks.length ? (
<PositionSection title={t("portfolio.stocks")} positions={stocks} /> <PositionSection
title={t("portfolio.stocks")}
positions={stocks}
denominators={denominators}
/>
) : null} ) : null}
{bonds.length ? ( {bonds.length ? (
<PositionSection title={t("portfolio.bonds")} positions={bonds} /> <PositionSection
title={t("portfolio.bonds")}
positions={bonds}
denominators={denominators}
/>
) : null} ) : null}
{other.length ? ( {other.length ? (
<PositionSection <PositionSection
title={t("portfolio.otherPositions")} title={t("portfolio.otherPositions")}
positions={other} positions={other}
denominators={denominators}
/> />
) : null} ) : null}
<Section title={t("portfolio.cashBalances")}> <Section title={t("portfolio.cashBalances")}>
@@ -269,40 +291,97 @@ function RecentEvent({ event }: { event: BrokerageEvent }) {
function PositionSection({ function PositionSection({
title, title,
positions, positions,
denominators,
}: { }: {
title: string; title: string;
positions: Portfolio["positions"]; positions: Portfolio["positions"];
denominators: Map<string, number>;
}) { }) {
const { t } = useTranslation();
return ( return (
<Section title={title}> <Section title={title}>
{positions.map((position) => ( <SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
<Card {positions.map((position) => (
className={classes.row} <PositionCard
key={`${position.figi}-${position.displayName}-${position.quantity}`} key={`${position.figi}-${position.displayName}-${position.quantity}`}
withBorder position={position}
> denominators={denominators}
<Group justify="space-between" align="flex-start" wrap="nowrap"> />
<Stack gap={2}> ))}
<Text fw={700}>{position.displayName}</Text> </SimpleGrid>
<Text c="dimmed" size="sm">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text fw={700}>{formatMoney(position.currentValue)}</Text>
<Text c="dimmed" size="sm">
{t("portfolio.return", {
value: formatMoney(position.absoluteReturn),
})}
</Text>
</Stack>
</Group>
</Card>
))}
</Section> </Section>
); );
} }
function PositionCard({
position,
denominators,
}: {
position: Portfolio["positions"][number];
denominators: Map<string, number>;
}) {
const { t } = useTranslation();
const share = position.currentValue?.currency
? formatShare(position.currentValue, {
currency: position.currentValue.currency,
value: String(denominators.get(position.currentValue.currency) ?? 0),
})
: null;
const returnClass = position.absoluteReturn
? Number(position.absoluteReturn.value) > 0
? classes.positive
: Number(position.absoluteReturn.value) < 0
? classes.negative
: undefined
: undefined;
return (
<Card className={classes.tile} withBorder>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2} miw={0}>
<Text fw={700} lineClamp={1}>
{position.displayName}
</Text>
{position.isin ? (
<Text c="dimmed" size="xs" className={classes.mono}>
{position.isin}
</Text>
) : null}
<Text c="dimmed" size="xs">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
{position.currentValue ? (
<Text fw={800} size="lg">
{formatMoney(position.currentValue)}
</Text>
) : null}
{position.absoluteReturn ? (
<Text size="sm" fw={600} className={returnClass}>
{t("portfolio.return", {
value: formatMoney(position.absoluteReturn),
})}
</Text>
) : null}
</Stack>
</Group>
{position.averagePurchasePrice || share ? (
<Group justify="space-between" mt="xs">
{position.averagePurchasePrice ? (
<Text c="dimmed" size="xs">
{t("portfolio.avgPrice", {
value: formatMoney(position.averagePurchasePrice),
})}
</Text>
) : null}
{share ? (
<Text c="dimmed" size="xs">
{t("portfolio.share", { value: share })}
</Text>
) : null}
</Group>
) : null}
</Card>
);
}
function AccountHeading({ account }: { account?: BrokerageAccount }) { function AccountHeading({ account }: { account?: BrokerageAccount }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
+1
View File
@@ -252,6 +252,7 @@ export interface components {
currentValue: components["schemas"]["MoneyDto"] | null; currentValue: components["schemas"]["MoneyDto"] | null;
averagePurchasePrice: components["schemas"]["MoneyDto"] | null; averagePurchasePrice: components["schemas"]["MoneyDto"] | null;
absoluteReturn: components["schemas"]["MoneyDto"] | null; absoluteReturn: components["schemas"]["MoneyDto"] | null;
isin: string | null;
}; };
PortfolioDto: { PortfolioDto: {
/** Format: date-time */ /** Format: date-time */
+6 -1
View File
@@ -1341,6 +1341,10 @@
"$ref": "#/components/schemas/MoneyDto" "$ref": "#/components/schemas/MoneyDto"
} }
] ]
},
"isin": {
"type": "string",
"nullable": true
} }
}, },
"required": [ "required": [
@@ -1350,7 +1354,8 @@
"quantity", "quantity",
"currentValue", "currentValue",
"averagePurchasePrice", "averagePurchasePrice",
"absoluteReturn" "absoluteReturn",
"isin"
] ]
}, },
"PortfolioDto": { "PortfolioDto": {
+2
View File
@@ -69,6 +69,8 @@ export const ru = {
cashBalances: "Денежные остатки", cashBalances: "Денежные остатки",
quantity: "Количество: {{value}}", quantity: "Количество: {{value}}",
return: "Доход: {{value}}", return: "Доход: {{value}}",
avgPrice: "Средняя цена: {{value}}",
share: "Доля: {{value}}",
}, },
ledger: { ledger: {
title: "Журнал операций", title: "Журнал операций",