diff --git a/src/entities/portfolio/index.ts b/src/entities/portfolio/index.ts index 08d2148..cf9c198 100644 --- a/src/entities/portfolio/index.ts +++ b/src/entities/portfolio/index.ts @@ -2,6 +2,7 @@ export type { Portfolio } from "./api/usePortfolio"; export { usePortfolio } from "./api/usePortfolio"; export { formatMoney, + formatShare, formatSnapshotTime, portfolioErrorCode, } from "./model/display"; diff --git a/src/entities/portfolio/model/display.test.ts b/src/entities/portfolio/model/display.test.ts index 504b2e5..198b8e7 100644 --- a/src/entities/portfolio/model/display.test.ts +++ b/src/entities/portfolio/model/display.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { formatMoney } from "./display"; +import { formatMoney, formatShare } from "./display"; describe("portfolio display helpers", () => { 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(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(); + }); }); diff --git a/src/entities/portfolio/model/display.ts b/src/entities/portfolio/model/display.ts index 21deffa..c73af7b 100644 --- a/src/entities/portfolio/model/display.ts +++ b/src/entities/portfolio/model/display.ts @@ -17,6 +17,21 @@ export function formatMoney(money: Money | null): string { 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 { return new Intl.DateTimeFormat("ru-RU", { dateStyle: "medium", diff --git a/src/pages/account/ui/AccountPage.module.css b/src/pages/account/ui/AccountPage.module.css index 109a4dc..c8a92b9 100644 --- a/src/pages/account/ui/AccountPage.module.css +++ b/src/pages/account/ui/AccountPage.module.css @@ -3,7 +3,7 @@ } .total, -.row, +.tile, .cash { border-color: var(--mantine-color-gray-3); background: var(--mantine-color-gray-0); @@ -14,7 +14,7 @@ border-top: 3px solid var(--mantine-color-blue-6); } -.row, +.tile, .cash { padding: var(--mantine-spacing-md); } @@ -24,8 +24,14 @@ justify-content: space-between; } -@media (max-width: 36em) { - .row :global(.mantine-Group-root) { - align-items: flex-start; - } +.positive { + color: var(--mantine-color-green-6); +} + +.negative { + color: var(--mantine-color-red-6); +} + +.mono { + font-family: var(--mantine-font-family-monospace); } diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx index 7beca37..8c445d4 100644 --- a/src/pages/account/ui/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage.test.tsx @@ -36,6 +36,7 @@ const portfolio: Portfolio = { currentValue: null, averagePurchasePrice: null, absoluteReturn: null, + isin: null, }, { figi: "FIGI-1", @@ -43,8 +44,9 @@ const portfolio: Portfolio = { type: "BOND", quantity: "1", currentValue: { currency: "RUB", value: "10" }, - averagePurchasePrice: null, + averagePurchasePrice: { currency: "RUB", value: "11" }, absoluteReturn: { currency: "RUB", value: "-1" }, + isin: "RU000A0JX0J2", }, ], cashBalances: [{ currency: "USD", value: "0.5" }], @@ -80,7 +82,7 @@ describe("AccountPage states", () => { 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(); expect( @@ -91,16 +93,20 @@ describe("AccountPage states", () => { "T-Bank временно недоступен. Показан последний успешный снимок.", ), ).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(); + 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", () => { diff --git a/src/pages/account/ui/AccountPage.view.tsx b/src/pages/account/ui/AccountPage.view.tsx index 922d0d3..1fd0366 100644 --- a/src/pages/account/ui/AccountPage.view.tsx +++ b/src/pages/account/ui/AccountPage.view.tsx @@ -19,7 +19,11 @@ import { useBrokerageEvents, } from "@/entities/brokerage-event"; 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 classes from "./AccountPage.module.css"; @@ -124,6 +128,15 @@ export function AccountPageLoaded({ const other = portfolio.positions.filter( (p) => p.type !== "STOCK" && p.type !== "BOND", ); + const denominators = new Map(); + 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 ( @@ -148,15 +161,24 @@ export function AccountPageLoaded({ ))} {stocks.length ? ( - + ) : null} {bonds.length ? ( - + ) : null} {other.length ? ( ) : null}
@@ -269,40 +291,97 @@ function RecentEvent({ event }: { event: BrokerageEvent }) { function PositionSection({ title, positions, + denominators, }: { title: string; positions: Portfolio["positions"]; + denominators: Map; }) { - const { t } = useTranslation(); return (
- {positions.map((position) => ( - - - - {position.displayName} - - {t("portfolio.quantity", { value: position.quantity })} - - - - {formatMoney(position.currentValue)} - - {t("portfolio.return", { - value: formatMoney(position.absoluteReturn), - })} - - - - - ))} + + {positions.map((position) => ( + + ))} +
); } +function PositionCard({ + position, + denominators, +}: { + position: Portfolio["positions"][number]; + denominators: Map; +}) { + 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 ( + + + + + {position.displayName} + + {position.isin ? ( + + {position.isin} + + ) : null} + + {t("portfolio.quantity", { value: position.quantity })} + + + + {position.currentValue ? ( + + {formatMoney(position.currentValue)} + + ) : null} + {position.absoluteReturn ? ( + + {t("portfolio.return", { + value: formatMoney(position.absoluteReturn), + })} + + ) : null} + + + {position.averagePurchasePrice || share ? ( + + {position.averagePurchasePrice ? ( + + {t("portfolio.avgPrice", { + value: formatMoney(position.averagePurchasePrice), + })} + + ) : null} + {share ? ( + + {t("portfolio.share", { value: share })} + + ) : null} + + ) : null} + + ); +} function AccountHeading({ account }: { account?: BrokerageAccount }) { const { t } = useTranslation(); return ( diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index b990e65..2b2dd9d 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -252,6 +252,7 @@ export interface components { currentValue: components["schemas"]["MoneyDto"] | null; averagePurchasePrice: components["schemas"]["MoneyDto"] | null; absoluteReturn: components["schemas"]["MoneyDto"] | null; + isin: string | null; }; PortfolioDto: { /** Format: date-time */ diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index 4a62ace..254681a 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -1341,6 +1341,10 @@ "$ref": "#/components/schemas/MoneyDto" } ] + }, + "isin": { + "type": "string", + "nullable": true } }, "required": [ @@ -1350,7 +1354,8 @@ "quantity", "currentValue", "averagePurchasePrice", - "absoluteReturn" + "absoluteReturn", + "isin" ] }, "PortfolioDto": { diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 5897db7..3a03113 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -69,6 +69,8 @@ export const ru = { cashBalances: "Денежные остатки", quantity: "Количество: {{value}}", return: "Доход: {{value}}", + avgPrice: "Средняя цена: {{value}}", + share: "Доля: {{value}}", }, ledger: { title: "Журнал операций",