feat: redesign portfolio position cards
This commit is contained in:
@@ -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";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Vendored
+1
@@ -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 */
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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: "Журнал операций",
|
||||||
|
|||||||
Reference in New Issue
Block a user