feat: add bond pages and portfolio navigation
This commit is contained in:
@@ -28,30 +28,6 @@
|
||||
.negative {
|
||||
color: var(--mantine-color-red-8);
|
||||
}
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--mantine-spacing-lg);
|
||||
}
|
||||
.metricValue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: var(--mantine-font-size-xl);
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.holding {
|
||||
background: var(--mantine-color-blue-0);
|
||||
padding: var(--mantine-spacing-lg);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
.accountRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr repeat(4, minmax(0, 1fr));
|
||||
gap: var(--mantine-spacing-sm);
|
||||
padding: var(--mantine-spacing-md) 0;
|
||||
border-top: 1px solid var(--wl-color-border);
|
||||
align-items: start;
|
||||
}
|
||||
.dividend {
|
||||
padding: var(--mantine-spacing-md);
|
||||
background: var(--mantine-color-green-0);
|
||||
@@ -62,30 +38,6 @@
|
||||
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
|
||||
gap: var(--mantine-spacing-lg);
|
||||
}
|
||||
.table {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
.controls {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
@media (max-width: 48em) {
|
||||
.section {
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
.metrics,
|
||||
.companyMetrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.accountRow {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.accountRow > :first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.embeddedSection {
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
padding-top: var(--mantine-spacing-lg);
|
||||
@@ -96,14 +48,11 @@
|
||||
background: var(--wl-color-subtle);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
.priceTable {
|
||||
margin-top: var(--mantine-spacing-md);
|
||||
}
|
||||
.priceTable table {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.priceTable th:nth-child(2),
|
||||
.priceTable td:nth-child(2) {
|
||||
text-align: right;
|
||||
@media (max-width: 48em) {
|
||||
.section {
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
.companyMetrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@ import {
|
||||
} from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
InstrumentChart as StockChart,
|
||||
InstrumentEvents as StockEvents,
|
||||
InstrumentPosition as StockPosition,
|
||||
InstrumentSection as StockSection,
|
||||
} from "@/features/instrument-details";
|
||||
import {
|
||||
formatDate,
|
||||
formatDecimal,
|
||||
@@ -19,10 +25,7 @@ import {
|
||||
formatSnapshotTime,
|
||||
} from "@/shared/lib";
|
||||
import { LoadingSkeleton } from "@/shared/ui";
|
||||
import { StockChart } from "./components/StockChart.view";
|
||||
import { StockDividends, StockEvents } from "./components/StockEvents.view";
|
||||
import { StockPosition } from "./components/StockPosition.view";
|
||||
import { StockSection } from "./components/StockSection.view";
|
||||
import { StockDividends } from "./components/StockEvents.view";
|
||||
import classes from "./StockPage.module.css";
|
||||
import type { StockPageVm } from "./StockPage.vm";
|
||||
|
||||
|
||||
@@ -15,32 +15,10 @@ import {
|
||||
useStockPosition,
|
||||
useStockQuote,
|
||||
} from "@/entities/stock";
|
||||
import { section } from "@/features/instrument-details";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
type SectionState = {
|
||||
hasData: boolean;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
refreshing: boolean;
|
||||
retry: () => void;
|
||||
};
|
||||
function section(query: {
|
||||
data: unknown;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
refetch: () => unknown;
|
||||
}): SectionState {
|
||||
return {
|
||||
hasData: Boolean(query.data),
|
||||
loading: !query.data && !query.isError,
|
||||
error: query.isError,
|
||||
refreshing: Boolean(query.data) && query.isFetching,
|
||||
retry: () => {
|
||||
void query.refetch();
|
||||
},
|
||||
};
|
||||
}
|
||||
const periods: StockPeriod[] = ["month", "halfYear", "year", "all"];
|
||||
const filters = ["all", "trades", "dividends", "other"] as const;
|
||||
type StockEventFilter = (typeof filters)[number];
|
||||
@@ -172,4 +150,4 @@ function useStockPageVm() {
|
||||
}
|
||||
type StockPageVm = ReturnType<typeof useStockPageVm>;
|
||||
|
||||
export { type SectionState, type StockPageVm, useStockPageVm };
|
||||
export { type StockPageVm, useStockPageVm };
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { AreaChart } from "@mantine/charts";
|
||||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import classes from "../StockPage.module.css";
|
||||
import type { StockPageVm } from "../StockPage.vm";
|
||||
import { StockSection } from "./StockSection.view";
|
||||
|
||||
type StockChartProps = { vm: StockPageVm };
|
||||
const StockChart: FC<StockChartProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StockSection
|
||||
title={t("stock.chart")}
|
||||
state={vm.candlesState}
|
||||
large
|
||||
controls={
|
||||
<div className={classes.controls}>
|
||||
<SegmentedControl
|
||||
aria-label={t("stock.period")}
|
||||
value={vm.period}
|
||||
onChange={vm.setPeriod}
|
||||
data={["month", "halfYear", "year", "all"].map((value) => ({
|
||||
value,
|
||||
label: t(`stock.periods.${value}`),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{vm.candles ? (
|
||||
vm.chartData.length ? (
|
||||
<>
|
||||
<AreaChart
|
||||
h={310}
|
||||
data={vm.chartData}
|
||||
dataKey="date"
|
||||
series={[
|
||||
{ name: "price", color: "blue.6", label: t("stock.price") },
|
||||
]}
|
||||
curveType="linear"
|
||||
withDots={vm.chartData.length === 1}
|
||||
withGradient
|
||||
gridAxis="y"
|
||||
tickLine="none"
|
||||
tooltipAnimationDuration={0}
|
||||
areaProps={{ isAnimationActive: false }}
|
||||
yAxisProps={{ domain: ["auto", "auto"], width: 64 }}
|
||||
referenceLines={
|
||||
vm.averageLine === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
y: vm.averageLine,
|
||||
ifOverflow: "extendDomain",
|
||||
color: "orange.7",
|
||||
label: t("stock.averageLine"),
|
||||
},
|
||||
]
|
||||
}
|
||||
valueFormatter={(value) =>
|
||||
new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(value)
|
||||
}
|
||||
aria-label={t("stock.chartLabel", {
|
||||
currency: vm.candles.currency ?? "",
|
||||
})}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt="sm">
|
||||
{t("stock.chartNote")}
|
||||
</Text>
|
||||
{vm.hasIncompleteCandle ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("stock.incomplete")}
|
||||
</Text>
|
||||
) : null}
|
||||
{vm.averageLine !== null ? (
|
||||
<Text size="sm" mt="xs">
|
||||
{t("stock.averageLine")}:{" "}
|
||||
{formatMoney(vm.position?.averagePurchasePrice ?? null)}
|
||||
</Text>
|
||||
) : null}
|
||||
<Accordion
|
||||
className={classes.priceTable}
|
||||
variant="contained"
|
||||
radius="md"
|
||||
>
|
||||
<Accordion.Item value="prices">
|
||||
<Accordion.Control>
|
||||
<Text fw={600}>{t("stock.priceTable")}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("stock.priceTableHint", { count: vm.chartData.length })}
|
||||
</Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<ScrollArea.Autosize
|
||||
mah={360}
|
||||
type="auto"
|
||||
viewportProps={{
|
||||
tabIndex: 0,
|
||||
role: "region",
|
||||
"aria-label": t("stock.priceTable"),
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
striped
|
||||
highlightOnHover
|
||||
stickyHeader
|
||||
verticalSpacing="sm"
|
||||
horizontalSpacing="md"
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("stock.date")}</Table.Th>
|
||||
<Table.Th>{t("stock.price")}</Table.Th>
|
||||
<Table.Th>{t("stock.candle")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{vm.priceRows.map((candle) => (
|
||||
<Table.Tr key={candle.time}>
|
||||
<Table.Td>{candle.date}</Table.Td>
|
||||
<Table.Td>
|
||||
{formatMoney(
|
||||
vm.candles?.currency
|
||||
? {
|
||||
value: candle.close,
|
||||
currency: vm.candles.currency,
|
||||
}
|
||||
: null,
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={candle.isComplete ? "gray" : "blue"}
|
||||
size="sm"
|
||||
>
|
||||
{t(
|
||||
candle.isComplete
|
||||
? "stock.complete"
|
||||
: "stock.forming",
|
||||
)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</>
|
||||
) : (
|
||||
<Text c="dimmed">{t("stock.noCandles")}</Text>
|
||||
)
|
||||
) : null}
|
||||
</StockSection>
|
||||
);
|
||||
};
|
||||
|
||||
export { StockChart };
|
||||
@@ -2,20 +2,17 @@ import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||
import { InstrumentSection as StockSection } from "@/features/instrument-details";
|
||||
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
import classes from "../StockPage.module.css";
|
||||
import type { StockPageVm } from "../StockPage.vm";
|
||||
import { StockSection } from "./StockSection.view";
|
||||
|
||||
type StockEventsProps = { vm: StockPageVm };
|
||||
const StockDividends: FC<StockEventsProps> = (props) => {
|
||||
@@ -167,67 +164,5 @@ const StockDividends: FC<StockEventsProps> = (props) => {
|
||||
</StockSection>
|
||||
);
|
||||
};
|
||||
const StockEvents: FC<StockEventsProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StockSection
|
||||
title={t("stock.history")}
|
||||
state={vm.operationsState}
|
||||
controls={
|
||||
<div className={classes.controls}>
|
||||
<SegmentedControl
|
||||
aria-label={t("stock.eventFilter")}
|
||||
value={vm.filter}
|
||||
onChange={vm.setFilter}
|
||||
data={["all", "trades", "dividends", "other"].map((value) => ({
|
||||
value,
|
||||
label: t(`stock.filters.${value}`),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{vm.operations ? (
|
||||
<>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("stock.historyNote")}
|
||||
</Text>
|
||||
{vm.eventRows.length ? (
|
||||
<Stack gap="sm" mt="md">
|
||||
{vm.eventRows.map((event) => (
|
||||
<LedgerEntryRow
|
||||
key={`${event.account?.id}:${event.id}`}
|
||||
operation={event}
|
||||
withDetails
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text c="dimmed" py="lg">
|
||||
{t("stock.noOperations")}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="space-between" mt="md">
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!vm.hasPrevious || vm.operationsState.refreshing}
|
||||
onClick={vm.onPrevious}
|
||||
>
|
||||
{t("stock.previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!vm.operations.hasNext || vm.operationsState.refreshing}
|
||||
onClick={vm.onNext}
|
||||
>
|
||||
{t("stock.next")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : null}
|
||||
</StockSection>
|
||||
);
|
||||
};
|
||||
|
||||
export { StockDividends, StockEvents };
|
||||
export { StockDividends };
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { Alert, Anchor, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
import classes from "../StockPage.module.css";
|
||||
import type { StockPageVm } from "../StockPage.vm";
|
||||
import { StockSection } from "./StockSection.view";
|
||||
|
||||
type StockPositionProps = { vm: StockPageVm };
|
||||
const StockPosition: FC<StockPositionProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
if (vm.position && !vm.position.accounts.length) return null;
|
||||
return (
|
||||
<StockSection title={t("stock.position")} state={vm.positionState}>
|
||||
{vm.position ? (
|
||||
<Stack gap="md">
|
||||
{vm.position.isStale ? (
|
||||
<Alert color="orange">{t("portfolio.staleWarning")}</Alert>
|
||||
) : null}
|
||||
<div className={`${classes.metrics} ${classes.holding}`}>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("stock.quantity")}
|
||||
</Text>
|
||||
<Text className={classes.metricValue}>
|
||||
{formatDecimal(vm.position.quantity)}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("stock.value")}
|
||||
</Text>
|
||||
<Text className={classes.metricValue}>
|
||||
{formatMoney(vm.position.currentValue)}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("stock.average")}
|
||||
</Text>
|
||||
<Text className={classes.metricValue}>
|
||||
{formatMoney(vm.position.averagePurchasePrice)}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("stock.openReturn")}
|
||||
</Text>
|
||||
<Text
|
||||
className={classes.metricValue}
|
||||
c={
|
||||
vm.position.absoluteReturn?.value.startsWith("-")
|
||||
? "red.8"
|
||||
: "green.8"
|
||||
}
|
||||
>
|
||||
{formatMoney(vm.position.absoluteReturn)}
|
||||
</Text>
|
||||
{vm.position.returnPercent !== null ? (
|
||||
<Text size="sm">
|
||||
{formatDecimal(vm.position.returnPercent)} %
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("stock.openReturnNote")}
|
||||
</Text>
|
||||
<div>
|
||||
{vm.position.accounts.map((account) => (
|
||||
<div className={classes.accountRow} key={account.account?.id}>
|
||||
{account.account ? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/accounts/${encodeURIComponent(account.account.id)}`}
|
||||
>
|
||||
{account.account.name}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text>{t("stock.portfolio")}</Text>
|
||||
)}
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("stock.quantity")}
|
||||
</Text>
|
||||
<Text>{formatDecimal(account.quantity)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("stock.average")}
|
||||
</Text>
|
||||
<Text>{formatMoney(account.averagePurchasePrice)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("stock.value")}
|
||||
</Text>
|
||||
<Text>{formatMoney(account.currentValue)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("stock.openReturn")}
|
||||
</Text>
|
||||
<Text
|
||||
c={
|
||||
account.absoluteReturn?.value.startsWith("-")
|
||||
? "red.8"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{formatMoney(account.absoluteReturn)}
|
||||
</Text>
|
||||
{account.returnPercent !== null ? (
|
||||
<Text size="xs">
|
||||
{formatDecimal(account.returnPercent)} %
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("stock.snapshotAt", {
|
||||
date: formatSnapshotTime(vm.position.observedAt),
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : null}
|
||||
</StockSection>
|
||||
);
|
||||
};
|
||||
|
||||
export { StockPosition };
|
||||
@@ -1,73 +0,0 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type { FC, PropsWithChildren, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingSkeleton } from "@/shared/ui";
|
||||
import classes from "../StockPage.module.css";
|
||||
import type { SectionState } from "../StockPage.vm";
|
||||
|
||||
type StockSectionProps = PropsWithChildren<{
|
||||
title: string;
|
||||
state: SectionState;
|
||||
controls?: ReactNode;
|
||||
large?: boolean;
|
||||
embedded?: boolean;
|
||||
}>;
|
||||
const StockSection: FC<StockSectionProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Paper
|
||||
component="section"
|
||||
className={props.embedded ? classes.embeddedSection : classes.section}
|
||||
withBorder={!props.embedded}
|
||||
>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={2} size="h3">
|
||||
{props.title}
|
||||
</Title>
|
||||
{props.controls}
|
||||
</Group>
|
||||
{props.state.refreshing ? (
|
||||
<Text role="status" size="xs" c="dimmed">
|
||||
{t("accounts.refreshing")}
|
||||
</Text>
|
||||
) : null}
|
||||
{props.state.error ? (
|
||||
<Alert color="orange" mb="sm" role="alert">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">
|
||||
{props.state.hasData
|
||||
? t("accounts.refreshError")
|
||||
: t("stock.sectionError")}
|
||||
</Text>
|
||||
<Button size="xs" variant="default" onClick={props.state.retry}>
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
) : null}
|
||||
{props.state.loading ? (
|
||||
<LoadingSkeleton
|
||||
label={t("stock.loadingSection", { section: props.title })}
|
||||
>
|
||||
<Stack>
|
||||
<Skeleton h={props.large ? 280 : 65} />
|
||||
<Skeleton h={14} w="60%" />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { StockSection };
|
||||
Reference in New Issue
Block a user