refactor: tighten types and reuse instrument UI
This commit is contained in:
@@ -3,6 +3,8 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type * as BrokerageEvents from "@/entities/brokerage-event";
|
||||
|
||||
import { useBrokerageEvents } from "@/entities/brokerage-event";
|
||||
|
||||
import { AccountEventsPage } from "./AccountEventsPage";
|
||||
@@ -18,7 +20,7 @@ vi.mock("@/features/tbank-connection", () => ({
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/entities/brokerage-event", async importOriginal => ({
|
||||
...(await importOriginal<typeof import("@/entities/brokerage-event")>()),
|
||||
...(await importOriginal<typeof BrokerageEvents>()),
|
||||
useBrokerageEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -156,8 +156,9 @@ function toIncomeDisplayModel(
|
||||
return null;
|
||||
}
|
||||
|
||||
const previous = meta?.dependencies?.find(({ role }) => role === "previous");
|
||||
const year = meta?.dependencies?.find(({ role }) => role === "year");
|
||||
const dependencies = meta?.dependencies ?? [];
|
||||
const previous = dependencies.find(({ role }) => role === "previous");
|
||||
const year = dependencies.find(({ role }) => role === "year");
|
||||
|
||||
return {
|
||||
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
|
||||
|
||||
@@ -78,7 +78,7 @@ const analytics
|
||||
},
|
||||
totalsByCurrency: [summary("RUB", "10", "2")],
|
||||
yearComparison: null,
|
||||
} satisfies IncomeAnalytics;
|
||||
} satisfies IncomeAnalytics & { instruments: [unknown] };
|
||||
|
||||
function comparison(currency: string) {
|
||||
return {
|
||||
|
||||
+5
-3
@@ -11,7 +11,7 @@ import {
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { assert, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
type BrokerageOperation,
|
||||
@@ -264,8 +264,8 @@ describe("AccountOperationsPage", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByLabelText("Период");
|
||||
expect(searches[0].get("from")).toBeNull();
|
||||
expect(searches[0].get("to")).toBeNull();
|
||||
expect(searches[0]?.get("from")).toBeNull();
|
||||
expect(searches[0]?.get("to")).toBeNull();
|
||||
|
||||
for (const expected of [
|
||||
"?categories=trade",
|
||||
@@ -597,6 +597,8 @@ it("recovers an invalid cursor at the first page without clearing applied filter
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
|
||||
await waitFor(() => expect(searches).toHaveLength(3));
|
||||
assert.isDefined(searches[0]);
|
||||
assert.isDefined(searches[2]);
|
||||
expect(searches[2].get("cursor")).toBeNull();
|
||||
for (const key of ["from", "to", "categories"])
|
||||
expect(searches[2].get(key)).toBe(searches[0].get(key));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
const portfolio: Portfolio
|
||||
const portfolio
|
||||
= {
|
||||
cashBalances: [{ currency: "USD", value: "1" }],
|
||||
isStale: true,
|
||||
@@ -42,10 +42,10 @@ const portfolio: Portfolio
|
||||
ticker: null,
|
||||
type: "FUND",
|
||||
},
|
||||
],
|
||||
] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
totalsByCurrency: [{ currency: "RUB", value: "150" }],
|
||||
};
|
||||
} satisfies Portfolio;
|
||||
|
||||
const position: Portfolio["positions"][number] = {
|
||||
...portfolio.positions[0],
|
||||
|
||||
@@ -59,13 +59,13 @@ describe("account position sections", () => {
|
||||
"bonds",
|
||||
"other",
|
||||
]);
|
||||
expect(sections[2].groups.map(group => group.positions[0].figi)).toEqual([
|
||||
expect(sections[2]?.groups.map(group => group.positions[0].figi)).toEqual([
|
||||
"FIGI-FUND",
|
||||
...otherTypes.map(type => `FIGI-${type}-NEXT`),
|
||||
]);
|
||||
expect(sections[0].groups[0].positions[0].shareText).toBe("40 %");
|
||||
expect(sections[1].groups[0].positions[0].shareText).toBe("20 %");
|
||||
expect(sections[2].groups[1].positions[0].shareText).toBe("20 %");
|
||||
expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("40 %");
|
||||
expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("20 %");
|
||||
expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("20 %");
|
||||
expect(snapshot).toEqual(original);
|
||||
});
|
||||
|
||||
@@ -125,10 +125,10 @@ describe("account position sections", () => {
|
||||
figi: null,
|
||||
},
|
||||
{ ...second, figi: null },
|
||||
])[0].groups,
|
||||
])[0]?.groups,
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
sections([position, { ...second, figi: "OTHER-FIGI" }])[0].groups,
|
||||
sections([position, { ...second, figi: "OTHER-FIGI" }])[0]?.groups,
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
|
||||
@@ -143,19 +143,19 @@ describe("account position sections", () => {
|
||||
figi: null,
|
||||
isin: null,
|
||||
},
|
||||
])[0].groups,
|
||||
])[0]?.groups,
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("preserves one-account cards and does not merge repeated rows on the same account", () => {
|
||||
expect(
|
||||
sections([position, { ...second, account: position.account }])[0].groups,
|
||||
sections([position, { ...second, account: position.account }])[0]?.groups,
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
sections([
|
||||
{ ...position, account: undefined },
|
||||
{ ...second, account: undefined },
|
||||
])[0].groups,
|
||||
])[0]?.groups,
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -170,9 +170,9 @@ describe("account position sections", () => {
|
||||
];
|
||||
|
||||
const before = structuredClone(source);
|
||||
const [group] = sections(source)[0].groups;
|
||||
const group = sections(source)[0]?.groups[0];
|
||||
expect(
|
||||
group.positions.map(item => [
|
||||
group?.positions.map(item => [
|
||||
item.quantity,
|
||||
item.currentValue,
|
||||
item.averagePurchasePrice,
|
||||
|
||||
@@ -11,7 +11,7 @@ type AccountPagePosition = Portfolio["positions"][number] & {
|
||||
|
||||
type AccountPagePositionGroup = {
|
||||
key: string;
|
||||
positions: AccountPagePosition[];
|
||||
positions: [AccountPagePosition, ...AccountPagePosition[]];
|
||||
};
|
||||
|
||||
type AccountPagePositionSection = {
|
||||
|
||||
@@ -67,7 +67,7 @@ function renderPage(ui: React.ReactNode) {
|
||||
);
|
||||
}
|
||||
|
||||
const portfolio: Portfolio
|
||||
const portfolio
|
||||
= {
|
||||
cashBalances: [{ currency: "USD", value: "0.5" }],
|
||||
isStale: true,
|
||||
@@ -97,10 +97,10 @@ const portfolio: Portfolio
|
||||
ticker: null,
|
||||
type: "BOND",
|
||||
},
|
||||
],
|
||||
] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
|
||||
};
|
||||
} satisfies Portfolio;
|
||||
|
||||
function position(
|
||||
overrides: Partial<Portfolio["positions"][number]>,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assert, describe, expect, it } from "vitest";
|
||||
|
||||
import { toAccountPagePositionSections } from "../../model/positions";
|
||||
import { portfolio, position, second } from "../../model/positions.fixtures";
|
||||
@@ -13,6 +13,8 @@ describe("Positions grouped by security", () => {
|
||||
...portfolio, positions: [position, second],
|
||||
})[0];
|
||||
|
||||
assert.isDefined(section);
|
||||
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
@@ -27,6 +29,8 @@ describe("Positions grouped by security", () => {
|
||||
).getAllByRole("listitem");
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
assert.isDefined(rows[0]);
|
||||
assert.isDefined(rows[1]);
|
||||
expect(
|
||||
within(rows[0]).getByRole("link", { name: "Основной" }),
|
||||
).toHaveAttribute("href", "/accounts/a");
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MantineProvider } from "@mantine/core";
|
||||
// eslint-disable-next-line testing-library/no-manual-cleanup -- Vitest globals are disabled, so RTL cannot register its automatic afterEach cleanup.
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { afterEach, assert, expect, it } from "vitest";
|
||||
|
||||
import type { AccountPagePosition, AccountPagePositionSection } from "../../model/positions";
|
||||
|
||||
@@ -76,6 +76,7 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
|
||||
expect(current.height, context).toBeGreaterThan(0);
|
||||
if (index === 0) continue;
|
||||
const previous = bounds[index - 1];
|
||||
assert.isDefined(previous);
|
||||
|
||||
if (kind === "other" && width === 1280) {
|
||||
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
.page {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.price {
|
||||
font-size: clamp(2rem, 4vw, 3.2rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.companyMetrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
|
||||
gap: var(--mantine-spacing-lg);
|
||||
}
|
||||
.embeddedSection {
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
padding-top: var(--mantine-spacing-lg);
|
||||
border-top: 1px solid var(--wl-color-border);
|
||||
}
|
||||
.companyMetrics > div {
|
||||
padding: var(--mantine-spacing-sm);
|
||||
background: var(--wl-color-subtle);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
@media (max-width: 48em) {
|
||||
.companyMetrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -14,23 +14,23 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
InstrumentChart,
|
||||
instrumentDetailsStyles,
|
||||
InstrumentEvents,
|
||||
InstrumentHeader,
|
||||
InstrumentMetric,
|
||||
InstrumentPosition,
|
||||
InstrumentQuoteStatus,
|
||||
} from "@/features/instrument-details";
|
||||
import {
|
||||
formatDate,
|
||||
formatDecimal,
|
||||
formatMoney,
|
||||
formatSnapshotTime,
|
||||
} from "@/shared/lib";
|
||||
|
||||
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 };
|
||||
|
||||
@@ -75,7 +75,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
{t("bond.percentOfNominal")}
|
||||
</Text>
|
||||
|
||||
<Text className={classes.price}>
|
||||
<Text className={instrumentDetailsStyles.price}>
|
||||
{vm.quote?.pricePercent == undefined
|
||||
? t("bond.unknown")
|
||||
: `${formatDecimal(vm.quote.pricePercent)} %`}
|
||||
@@ -97,17 +97,17 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
</Text>
|
||||
|
||||
<Group gap="xl" my="sm">
|
||||
<Metric
|
||||
<InstrumentMetric
|
||||
label={t("bond.cleanPrice")}
|
||||
value={formatMoney(vm.quote?.cleanPrice)}
|
||||
/>
|
||||
|
||||
<Metric
|
||||
<InstrumentMetric
|
||||
label={t("bond.aci")}
|
||||
value={formatMoney(vm.quote?.aci)}
|
||||
/>
|
||||
|
||||
<Metric
|
||||
<InstrumentMetric
|
||||
label={t("bond.dirtyPrice")}
|
||||
value={formatMoney(vm.quote?.dirtyPrice)}
|
||||
/>
|
||||
@@ -118,19 +118,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
</Text>
|
||||
|
||||
<Group>
|
||||
<Badge color="gray" variant="light">
|
||||
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
|
||||
defaultValue: t("stock.trading.unknown"),
|
||||
})}
|
||||
</Badge>
|
||||
|
||||
{!!vm.quote?.priceAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.priceAt", {
|
||||
date: formatSnapshotTime(vm.quote.priceAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
<InstrumentQuoteStatus quote={vm.quote} />
|
||||
</Group>
|
||||
|
||||
{!!vm.quote?.unavailable.length && (
|
||||
@@ -143,7 +131,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack className={classes.page} gap="lg">
|
||||
<Stack className={instrumentDetailsStyles.page} gap="lg">
|
||||
<InstrumentHeader
|
||||
logoSrc={vm.logoSrc}
|
||||
name={bond.name}
|
||||
@@ -180,29 +168,29 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className={classes.embeddedSection}>
|
||||
<div className={instrumentDetailsStyles.embeddedSection}>
|
||||
<Title mb="md" order={2} size="h3">
|
||||
{t("bond.terms")}
|
||||
</Title>
|
||||
|
||||
<div className={classes.companyMetrics}>
|
||||
<Metric
|
||||
<div className={instrumentDetailsStyles.metricsGrid}>
|
||||
<InstrumentMetric
|
||||
label={t("bond.nominal")}
|
||||
value={formatMoney(bond.nominal)}
|
||||
/>
|
||||
|
||||
<Metric
|
||||
<InstrumentMetric
|
||||
label={t("bond.initialNominal")}
|
||||
value={formatMoney(bond.initialNominal)}
|
||||
/>
|
||||
|
||||
<Metric label={t("bond.maturity")} value={maturityDate} />
|
||||
<InstrumentMetric label={t("bond.maturity")} value={maturityDate} />
|
||||
|
||||
<Metric label={t("bond.callDate")} value={callDate} />
|
||||
<InstrumentMetric label={t("bond.callDate")} value={callDate} />
|
||||
|
||||
<Metric label={t("bond.couponFrequency")} value={couponFrequency} />
|
||||
<InstrumentMetric label={t("bond.couponFrequency")} value={couponFrequency} />
|
||||
|
||||
<Metric label={t("bond.lot")} value={lotSize} />
|
||||
<InstrumentMetric label={t("bond.lot")} value={lotSize} />
|
||||
</div>
|
||||
</div>
|
||||
</InstrumentHeader>
|
||||
|
||||
@@ -5,11 +5,9 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BondCoupons as Coupons } from "@/entities/bond";
|
||||
|
||||
import { InstrumentSection, type SectionState } from "@/features/instrument-details";
|
||||
import { InstrumentMetric, 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"];
|
||||
@@ -116,10 +114,10 @@ const Coupon: FC<CouponProps> = (props) => {
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Metric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
|
||||
<InstrumentMetric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
|
||||
|
||||
{!!coupon.estimatedAmount && (
|
||||
<Metric
|
||||
<InstrumentMetric
|
||||
label={t("stock.yourEstimate")}
|
||||
value={formatMoney(coupon.estimatedAmount)}
|
||||
/>
|
||||
|
||||
@@ -5,12 +5,9 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BondMetrics as Metrics } from "@/entities/bond";
|
||||
|
||||
import { InstrumentSection, type SectionState } from "@/features/instrument-details";
|
||||
import { instrumentDetailsStyles, InstrumentMetric, 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) => {
|
||||
@@ -32,17 +29,17 @@ const BondMetrics: FC<BondMetricsProps> = (props) => {
|
||||
}
|
||||
|
||||
const couponDate = metrics?.couponDate
|
||||
? formatDate(metrics?.couponDate)
|
||||
? formatDate(metrics.couponDate)
|
||||
: t("bond.unknown");
|
||||
|
||||
const effectiveYield = metrics?.effectiveYield
|
||||
? `${formatDecimal(metrics?.effectiveYield.value)} %`
|
||||
? `${formatDecimal(metrics.effectiveYield.value)} %`
|
||||
: t("bond.unknown");
|
||||
|
||||
const couponYield
|
||||
= metrics?.couponYieldPercent == undefined
|
||||
? t("bond.unknown")
|
||||
: `${formatDecimal(metrics?.couponYieldPercent)} %`;
|
||||
: `${formatDecimal(metrics.couponYieldPercent)} %`;
|
||||
|
||||
let yieldContent;
|
||||
|
||||
@@ -50,13 +47,13 @@ const BondMetrics: FC<BondMetricsProps> = (props) => {
|
||||
yieldContent = (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.yieldSource", {
|
||||
at: formatSnapshotTime(metrics?.effectiveYield.tradeAt),
|
||||
date: formatDate(metrics?.effectiveYield.date),
|
||||
at: formatSnapshotTime(metrics.effectiveYield.tradeAt),
|
||||
date: formatDate(metrics.effectiveYield.date),
|
||||
price: formatDecimal(
|
||||
metrics?.effectiveYield.pricePercent,
|
||||
metrics.effectiveYield.pricePercent,
|
||||
),
|
||||
target: t(
|
||||
`bond.yieldTargets.${metrics?.effectiveYield.dateType}`,
|
||||
`bond.yieldTargets.${metrics.effectiveYield.dateType}`,
|
||||
{ defaultValue: t("bond.yieldTargets.unknown") },
|
||||
),
|
||||
})}
|
||||
@@ -75,26 +72,26 @@ const BondMetrics: FC<BondMetricsProps> = (props) => {
|
||||
<InstrumentSection state={state} title={t("bond.metrics")}>
|
||||
{!!metrics && (
|
||||
<Stack gap="md">
|
||||
<div className={classes.companyMetrics}>
|
||||
<Metric label={t("bond.creditRating")} value={rating} />
|
||||
<div className={instrumentDetailsStyles.metricsGrid}>
|
||||
<InstrumentMetric label={t("bond.creditRating")} value={rating} />
|
||||
|
||||
<Metric
|
||||
<InstrumentMetric
|
||||
label={t("bond.couponAmount")}
|
||||
value={formatMoney(metrics.coupon)}
|
||||
/>
|
||||
|
||||
<Metric
|
||||
<InstrumentMetric
|
||||
label={t("bond.couponType")}
|
||||
value={t(`bond.couponTypes.${metrics.couponType}`, {
|
||||
defaultValue: t("bond.unknown"),
|
||||
})}
|
||||
/>
|
||||
|
||||
<Metric label={t("bond.couponDate")} value={couponDate} />
|
||||
<InstrumentMetric label={t("bond.couponDate")} value={couponDate} />
|
||||
|
||||
<Metric label={t("bond.effectiveYield")} value={effectiveYield} />
|
||||
<InstrumentMetric label={t("bond.effectiveYield")} value={effectiveYield} />
|
||||
|
||||
<Metric label={t("bond.couponYield")} value={couponYield} />
|
||||
<InstrumentMetric label={t("bond.couponYield")} value={couponYield} />
|
||||
</div>
|
||||
|
||||
{!!metrics.creditRating && (
|
||||
@@ -128,10 +125,10 @@ const BondMetrics: FC<BondMetricsProps> = (props) => {
|
||||
{t("bond.couponYieldNote")}
|
||||
</Text>
|
||||
|
||||
{!!metrics?.couponYieldPriceAt && (
|
||||
{!!metrics.couponYieldPriceAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponYieldPriceAt", {
|
||||
date: formatSnapshotTime(metrics?.couponYieldPriceAt),
|
||||
date: formatSnapshotTime(metrics.couponYieldPriceAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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 };
|
||||
@@ -20,7 +20,7 @@ function useBondsPageVm() {
|
||||
result.data?.map(bond => ({
|
||||
...bond,
|
||||
logoSrc: instrumentLogoUrl(bond.logoName),
|
||||
to: `/bonds/${encodeURIComponent(bond.slug ?? bond.instrumentUid)}`,
|
||||
to: `/bonds/${encodeURIComponent(bond.slug)}`,
|
||||
})) ?? [],
|
||||
loading:
|
||||
canSearch
|
||||
|
||||
@@ -32,19 +32,6 @@
|
||||
.operation {
|
||||
min-width: 0;
|
||||
}
|
||||
.operation p {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.operation > div > div {
|
||||
min-width: 0;
|
||||
}
|
||||
@media (min-width: 36em) {
|
||||
.operation > div:first-child {
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%);
|
||||
}
|
||||
}
|
||||
|
||||
.operations[data-pending] {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
@@ -102,12 +102,13 @@ const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
initial={row.title[0]}
|
||||
initial={row.title.charAt(0)}
|
||||
metadata={row.metadata}
|
||||
title={row.title}
|
||||
tone={row.tone}
|
||||
value={row.value}
|
||||
valueLabel={t(`bank.kinds.${row.item.kind}`)}
|
||||
wrap
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
@@ -128,6 +129,8 @@ const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Pagination } from "@mantine/core";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { Pagination } from "@mantine/core";
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DateInput } from "@mantine/dates";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { useController, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
.fieldset {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -90,7 +90,7 @@ function createOperationSchema({
|
||||
&& !categories?.some(
|
||||
c =>
|
||||
c.id
|
||||
=== (values.categoryId || categories?.find(c => c.isDefault)?.id),
|
||||
=== (values.categoryId || categories.find(c => c.isDefault)?.id),
|
||||
)
|
||||
)
|
||||
context.addIssue({
|
||||
|
||||
@@ -467,7 +467,7 @@ describe("OperationForm", () => {
|
||||
expect(screen.getByLabelText(/Сумма/)).toHaveValue("150");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await waitFor(() => expect(bodies).toHaveLength(2));
|
||||
expect(bodies[1].version).toBe(1);
|
||||
expect(bodies[1]?.version).toBe(1);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Использовать актуальную версию 2" }),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FC, ReactNode, SubmitEventHandler } from "react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
import { Button,
|
||||
Fieldset,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
@@ -20,7 +20,6 @@ import { formatMoney } from "@/shared/lib";
|
||||
import type { Choice, OperationFieldsProps } from "./OperationForm.types";
|
||||
|
||||
import { useOperationFieldsVm } from "./OperationFields.vm";
|
||||
import classes from "./OperationForm.module.css";
|
||||
import { useRefundFieldsVm } from "./RefundFields.vm";
|
||||
|
||||
const OperationFields: FC<OperationFieldsProps> = (props) => {
|
||||
@@ -203,7 +202,7 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
|
||||
title={displayTitle}
|
||||
>
|
||||
<form onSubmit={props.onSubmit}>
|
||||
<fieldset className={classes.fieldset} disabled={props.pending}>
|
||||
<Fieldset disabled={props.pending} variant="unstyled">
|
||||
<Stack>
|
||||
<OperationFields {...props.fields} />
|
||||
|
||||
@@ -283,7 +282,7 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</fieldset>
|
||||
</Fieldset>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assert, describe, expect, it } from "vitest";
|
||||
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
@@ -62,9 +62,9 @@ describe("CardsPage", () => {
|
||||
it("preserves exact negative opening amount and the creation intent through unknown failure", async () => {
|
||||
renderCards();
|
||||
await screen.findByRole("heading", { name: "Добавьте первую карту" });
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: "Добавить карту" })[0],
|
||||
);
|
||||
const creationButton = screen.getAllByRole("button", { name: "Добавить карту" })[0];
|
||||
assert.isDefined(creationButton);
|
||||
fireEvent.click(creationButton);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const submitted: Record<string, unknown>[] = [];
|
||||
let resolve: (() => void) | undefined;
|
||||
@@ -80,7 +80,7 @@ describe("CardsPage", () => {
|
||||
return HttpResponse.error();
|
||||
}
|
||||
|
||||
return HttpResponse.json({ id: submitted[0].id }, { status: 201 });
|
||||
return HttpResponse.json({ id: submitted[0]?.id }, { status: 201 });
|
||||
}),
|
||||
);
|
||||
fireEvent.change(within(dialog).getByLabelText(/Название карты/), {
|
||||
|
||||
@@ -75,6 +75,8 @@ const CardsPageViewCards: FC<CardsPageViewProps> = (props) => {
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const CardsPageView: FC<CardsPageViewProps> = (props) => {
|
||||
|
||||
@@ -1,39 +1,11 @@
|
||||
.page {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.price {
|
||||
font-size: clamp(2rem, 4vw, 3.2rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.positive {
|
||||
color: var(--mantine-color-green-8);
|
||||
color: var(--wl-color-positive);
|
||||
}
|
||||
.negative {
|
||||
color: var(--mantine-color-red-8);
|
||||
color: var(--wl-color-negative);
|
||||
}
|
||||
.dividend {
|
||||
padding: var(--mantine-spacing-md);
|
||||
background: var(--mantine-color-green-0);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
.companyMetrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
|
||||
gap: var(--mantine-spacing-lg);
|
||||
}
|
||||
.companyMetrics > div {
|
||||
padding: var(--mantine-spacing-sm);
|
||||
background: var(--wl-color-subtle);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
@media (max-width: 48em) {
|
||||
.companyMetrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { FC } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Skeleton,
|
||||
@@ -13,7 +12,10 @@ import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
instrumentDetailsStyles,
|
||||
InstrumentHeader,
|
||||
InstrumentMetric,
|
||||
InstrumentQuoteStatus,
|
||||
InstrumentChart as StockChart,
|
||||
InstrumentEvents as StockEvents,
|
||||
InstrumentPosition as StockPosition,
|
||||
@@ -23,7 +25,6 @@ import {
|
||||
formatDate,
|
||||
formatDecimal,
|
||||
formatMoney,
|
||||
formatSnapshotTime,
|
||||
} from "@/shared/lib";
|
||||
|
||||
import type { StockPageVm } from "./StockPage.vm";
|
||||
@@ -31,6 +32,16 @@ import type { StockPageVm } from "./StockPage.vm";
|
||||
import { StockDividends } from "./components/StockDividends.view";
|
||||
import classes from "./StockPage.module.css";
|
||||
|
||||
const compactMetricFormatter = new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: 2,
|
||||
notation: "compact",
|
||||
});
|
||||
|
||||
const metricFormatter = new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: 2,
|
||||
notation: "standard",
|
||||
});
|
||||
|
||||
type StockPageViewProps = { vm: StockPageVm };
|
||||
|
||||
const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
@@ -64,13 +75,13 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
else {
|
||||
quoteContent = (
|
||||
<div aria-live="polite">
|
||||
<Text className={classes.price} mt="lg">
|
||||
<Text className={instrumentDetailsStyles.price} mt="lg">
|
||||
{formatMoney(vm.quote?.price)}
|
||||
</Text>
|
||||
|
||||
{!!vm.quote?.change && (
|
||||
<Text className={appearanceClass} fw={600}>
|
||||
{vm.quote?.change?.value.startsWith("-") ? "" : "+"}
|
||||
{vm.quote.change.value.startsWith("-") ? "" : "+"}
|
||||
|
||||
{formatMoney(vm.quote.change)}
|
||||
|
||||
@@ -95,19 +106,7 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
)}
|
||||
|
||||
<Group gap="sm" mt="sm">
|
||||
<Badge color="gray" variant="light">
|
||||
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
|
||||
defaultValue: t("stock.trading.unknown"),
|
||||
})}
|
||||
</Badge>
|
||||
|
||||
{!!vm.quote?.priceAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.priceAt", {
|
||||
date: formatSnapshotTime(vm.quote.priceAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
<InstrumentQuoteStatus quote={vm.quote} />
|
||||
</Group>
|
||||
|
||||
{!!vm.quote?.unavailable.length && (
|
||||
@@ -130,25 +129,16 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
if (vm.company) {
|
||||
if (vm.company.metrics.length > 0) {
|
||||
companyMetricsContent = (
|
||||
<div className={classes.companyMetrics}>
|
||||
{vm.company.metrics.map(metric => (
|
||||
<div key={metric.key}>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t(`stock.metrics.${metric.key}`)}
|
||||
<div className={instrumentDetailsStyles.metricsGrid}>
|
||||
{vm.company.metrics.map((metric) => {
|
||||
const label = [t(`stock.metrics.${metric.key}`), metric.period].filter(Boolean).join(" · ");
|
||||
const formatter = metric.unit === "money" ? compactMetricFormatter : metricFormatter;
|
||||
const value = `${formatter.format(metric.value)} ${metricUnit(metric, t("stock.currencyUnknown"))}`;
|
||||
|
||||
{metric.period ? ` · ${metric.period}` : ""}
|
||||
</Text>
|
||||
|
||||
<Text fw={600} size="lg">
|
||||
{new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: 2,
|
||||
notation:
|
||||
metric.unit === "money" ? "compact" : "standard",
|
||||
}).format(metric.value)}{" "}
|
||||
{metricUnit(metric, t("stock.currencyUnknown"))}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
return (
|
||||
<InstrumentMetric key={metric.key} label={label} value={value} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -158,7 +148,7 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack className={classes.page} gap="lg">
|
||||
<Stack className={instrumentDetailsStyles.page} gap="lg">
|
||||
<InstrumentHeader
|
||||
logoSrc={vm.logoSrc}
|
||||
name={stock.name}
|
||||
|
||||
@@ -20,7 +20,7 @@ function useStocksPageVm() {
|
||||
result.data?.map(stock => ({
|
||||
...stock,
|
||||
logoSrc: instrumentLogoUrl(stock.logoName),
|
||||
to: `/stocks/${encodeURIComponent(stock.slug ?? stock.instrumentUid)}`,
|
||||
to: `/stocks/${encodeURIComponent(stock.slug)}`,
|
||||
})) ?? [],
|
||||
loading:
|
||||
canSearch
|
||||
|
||||
Reference in New Issue
Block a user