refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
export { section } from "./model/section";
|
||||
export { useInstrumentOperations } from "./model/useInstrumentOperations";
|
||||
export { InstrumentChart } from "./ui/InstrumentChart.view";
|
||||
export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view";
|
||||
export { InstrumentEvents } from "./ui/InstrumentEvents.view";
|
||||
export { InstrumentPosition } from "./ui/InstrumentPosition.view";
|
||||
export { InstrumentSection } from "./ui/InstrumentSection.view";
|
||||
|
||||
@@ -9,6 +9,7 @@ describe("instrument source and transport presentation", () => {
|
||||
"keeps %s separate from successful transport",
|
||||
(availability) => {
|
||||
const data = availability === "missing" ? undefined : { rows: [] };
|
||||
|
||||
const state = section({
|
||||
data,
|
||||
isError: false,
|
||||
@@ -16,6 +17,7 @@ describe("instrument source and transport presentation", () => {
|
||||
meta: resource(data, { availability }).meta,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
expect(state.loading).toBe(availability === "missing");
|
||||
expect(state.partial).toBe(availability === "partial");
|
||||
expect(state.hasData).toBe(availability !== "missing");
|
||||
@@ -24,13 +26,16 @@ describe("instrument source and transport presentation", () => {
|
||||
it("preserves a full fresh source and timestamp when force fails, while offering scoped retry", () => {
|
||||
const retry = vi.fn();
|
||||
const refetch = vi.fn();
|
||||
|
||||
const meta = resource([], {
|
||||
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
|
||||
}).meta;
|
||||
|
||||
const state = section(
|
||||
{ data: [], isError: false, isFetching: false, meta, refetch },
|
||||
retry,
|
||||
);
|
||||
|
||||
expect(state).toMatchObject({
|
||||
error: true,
|
||||
hasData: true,
|
||||
@@ -43,17 +48,16 @@ describe("instrument source and transport presentation", () => {
|
||||
expect(refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
it("shows source failure without a false loading or empty result", () => {
|
||||
const state = section((
|
||||
{
|
||||
data: undefined,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
meta: resource(null, {
|
||||
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
|
||||
}).meta,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
));
|
||||
const state = section({
|
||||
data: undefined,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
meta: resource(null, {
|
||||
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
|
||||
}).meta,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
expect(state).toMatchObject({
|
||||
error: true,
|
||||
hasData: false,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { paths } from "@/shared/api";
|
||||
|
||||
type Meta
|
||||
= paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
|
||||
|
||||
type SectionState = {
|
||||
error: boolean;
|
||||
hasData: boolean;
|
||||
@@ -13,6 +14,7 @@ type SectionState = {
|
||||
sourceError?: boolean;
|
||||
stale?: boolean;
|
||||
};
|
||||
|
||||
function section(
|
||||
query: {
|
||||
data: unknown;
|
||||
@@ -24,6 +26,7 @@ function section(
|
||||
retry?: () => unknown,
|
||||
): SectionState {
|
||||
const isError = query.isError || query.meta?.sync.state === "failed";
|
||||
|
||||
return {
|
||||
error: isError,
|
||||
hasData: Boolean(query.data),
|
||||
|
||||
@@ -6,7 +6,9 @@ import type { SectionState } from "./section";
|
||||
type Candles = NonNullable<
|
||||
paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||
>;
|
||||
|
||||
type ChartRow = Candles["candles"][number] & { date: string; price: number };
|
||||
|
||||
type InstrumentChartVm = {
|
||||
averageLine: null | number;
|
||||
candles?: Candles;
|
||||
@@ -18,6 +20,7 @@ type InstrumentChartVm = {
|
||||
priceRows: ChartRow[];
|
||||
setPeriod: (value: string) => void;
|
||||
};
|
||||
|
||||
type InstrumentEventsVm = {
|
||||
eventRows: BrokerageOperation[];
|
||||
filter: string;
|
||||
@@ -28,10 +31,12 @@ type InstrumentEventsVm = {
|
||||
operationsState: SectionState;
|
||||
setFilter: (value: string) => void;
|
||||
};
|
||||
|
||||
type InstrumentPositionVm = {
|
||||
position?: Position;
|
||||
positionState: SectionState;
|
||||
};
|
||||
|
||||
type Position = NonNullable<
|
||||
paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||
>;
|
||||
|
||||
@@ -14,19 +14,28 @@ function useInstrumentOperations(
|
||||
categories: OperationCategory[],
|
||||
) {
|
||||
const scope = `${uid}:${filter}`;
|
||||
|
||||
const [pagination, setPagination] = useState<{
|
||||
cursors: string[];
|
||||
scope: string;
|
||||
}>({ cursors: [], scope });
|
||||
|
||||
const cursors = pagination.scope === scope ? pagination.cursors : [];
|
||||
const client = useQueryClient();
|
||||
const firstPage = { categories, instrumentUid: uid, limit: 20 };
|
||||
|
||||
const operations = useBrokerageOperations(uid ? "all" : undefined, {
|
||||
...firstPage,
|
||||
cursor: cursors.at(-1),
|
||||
});
|
||||
|
||||
const retry = (retryScreen: () => unknown) => {
|
||||
if (isApiErrorCode(apiErrorCode(operations.error), ApiErrorCode.InvalidOperationsCursor)) {
|
||||
if (
|
||||
isApiErrorCode(
|
||||
apiErrorCode(operations.error),
|
||||
ApiErrorCode.InvalidOperationsCursor,
|
||||
)
|
||||
) {
|
||||
setPagination({ cursors: [], scope });
|
||||
void client.invalidateQueries({
|
||||
exact: true,
|
||||
@@ -35,6 +44,7 @@ function useInstrumentOperations(
|
||||
}
|
||||
else void retryScreen();
|
||||
};
|
||||
|
||||
return {
|
||||
hasPrevious: cursors.length > 0,
|
||||
onNext: () => {
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { AreaChart } from "@mantine/charts";
|
||||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatDecimal, formatMoney } from "@/shared/lib";
|
||||
|
||||
import type { InstrumentChartVm } from "../model/types";
|
||||
|
||||
import classes from "./InstrumentDetails.module.css";
|
||||
import { InstrumentSection } from "./InstrumentSection.view";
|
||||
|
||||
type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
|
||||
const InstrumentChart: FC<InstrumentChartProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
const priceLabel = (value: string) => {
|
||||
if (props.priceUnit === "percent") return `${formatDecimal(value)} %`;
|
||||
const currency = vm.candles?.currency;
|
||||
return formatMoney(currency ? { currency, value } : undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<InstrumentSection
|
||||
controls={(
|
||||
<div className={classes.controls}>
|
||||
<SegmentedControl
|
||||
aria-label={t("stock.period")}
|
||||
data={["month", "halfYear", "year", "all"].map(value => ({
|
||||
label: t(`stock.periods.${value}`),
|
||||
value,
|
||||
}))}
|
||||
onChange={vm.setPeriod}
|
||||
value={vm.period}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
large
|
||||
state={vm.candlesState}
|
||||
title={t("stock.chart")}
|
||||
>
|
||||
{!!(vm.candles) && ((
|
||||
vm.chartData.length > 0
|
||||
? (
|
||||
<>
|
||||
<AreaChart
|
||||
areaProps={{ isAnimationActive: false }}
|
||||
aria-label={t(
|
||||
props.priceUnit === "percent"
|
||||
? "bond.chartLabel"
|
||||
: "stock.chartLabel",
|
||||
{
|
||||
currency:
|
||||
props.priceUnit === "percent"
|
||||
? t("bond.percentOfNominal")
|
||||
: (vm.candles.currency ?? ""),
|
||||
},
|
||||
)}
|
||||
curveType="linear"
|
||||
data={vm.chartData}
|
||||
dataKey="date"
|
||||
gridAxis="y"
|
||||
h={310}
|
||||
referenceLines={
|
||||
vm.averageLine === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
color: "orange.7",
|
||||
ifOverflow: "extendDomain",
|
||||
label: t("stock.averageLine"),
|
||||
y: vm.averageLine,
|
||||
},
|
||||
]
|
||||
}
|
||||
series={[
|
||||
{ color: "blue.6", label: t("stock.price"), name: "price" },
|
||||
]}
|
||||
tickLine="none"
|
||||
tooltipAnimationDuration={0}
|
||||
valueFormatter={value =>
|
||||
new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(value)}
|
||||
withDots={vm.chartData.length === 1}
|
||||
withGradient
|
||||
yAxisProps={{ domain: ["auto", "auto"], width: 64 }}
|
||||
/>
|
||||
<Text c="dimmed" mt="sm" size="xs">
|
||||
{t(
|
||||
props.priceUnit === "percent"
|
||||
? "bond.chartNote"
|
||||
: "stock.chartNote",
|
||||
)}
|
||||
</Text>
|
||||
{!!(vm.hasIncompleteCandle) && ((
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.incomplete")}
|
||||
</Text>
|
||||
))}
|
||||
{vm.averageLine !== null && ((
|
||||
<Text mt="xs" size="sm">
|
||||
{t("stock.averageLine")}
|
||||
:
|
||||
{" "}
|
||||
{formatMoney(vm.position?.averagePurchasePrice)}
|
||||
</Text>
|
||||
))}
|
||||
<Accordion
|
||||
className={classes.priceTable}
|
||||
radius="md"
|
||||
variant="contained"
|
||||
>
|
||||
<Accordion.Item value="prices">
|
||||
<Accordion.Control>
|
||||
<Text fw={600}>{t("stock.priceTable")}</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.priceTableHint", { count: vm.chartData.length })}
|
||||
</Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<ScrollArea.Autosize
|
||||
mah={360}
|
||||
type="auto"
|
||||
viewportProps={{
|
||||
"aria-label": t("stock.priceTable"),
|
||||
"role": "region",
|
||||
"tabIndex": 0,
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
highlightOnHover
|
||||
horizontalSpacing="md"
|
||||
stickyHeader
|
||||
striped
|
||||
verticalSpacing="sm"
|
||||
>
|
||||
<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>
|
||||
{priceLabel(candle.close)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={candle.isComplete ? "gray" : "blue"}
|
||||
size="sm"
|
||||
variant="light"
|
||||
>
|
||||
{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(
|
||||
vm.candlesState.partial
|
||||
? "investment.partial"
|
||||
: "stock.noCandles",
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
))}
|
||||
</InstrumentSection>
|
||||
);
|
||||
};
|
||||
|
||||
export { InstrumentChart };
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { AreaChart } from "@mantine/charts";
|
||||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
import type { InstrumentChartVm } from "../../model/types";
|
||||
|
||||
import classes from "../InstrumentDetails.module.css";
|
||||
import { InstrumentSection } from "../InstrumentSection.view";
|
||||
import { useInstrumentChartVm } from "./InstrumentChart.vm";
|
||||
|
||||
type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
|
||||
|
||||
const InstrumentChart: FC<InstrumentChartProps> = (props) => {
|
||||
const {
|
||||
chartLabel,
|
||||
formatChartValue,
|
||||
periodOptions,
|
||||
priceRows,
|
||||
referenceLines,
|
||||
t,
|
||||
vm,
|
||||
} = useInstrumentChartVm(props);
|
||||
|
||||
let chartContent;
|
||||
|
||||
if (vm.candles) {
|
||||
if (vm.chartData.length > 0) {
|
||||
chartContent = (
|
||||
<>
|
||||
<AreaChart
|
||||
areaProps={{ isAnimationActive: false }}
|
||||
aria-label={chartLabel}
|
||||
curveType="linear"
|
||||
data={vm.chartData}
|
||||
dataKey="date"
|
||||
gridAxis="y"
|
||||
h={310}
|
||||
referenceLines={referenceLines}
|
||||
series={[
|
||||
{ color: "blue.6", label: t("stock.price"), name: "price" },
|
||||
]}
|
||||
tickLine="none"
|
||||
tooltipAnimationDuration={0}
|
||||
valueFormatter={formatChartValue}
|
||||
withDots={vm.chartData.length === 1}
|
||||
withGradient
|
||||
yAxisProps={{ domain: ["auto", "auto"], width: 64 }}
|
||||
/>
|
||||
|
||||
<Text c="dimmed" mt="sm" size="xs">
|
||||
{t(
|
||||
props.priceUnit === "percent"
|
||||
? "bond.chartNote"
|
||||
: "stock.chartNote",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{!!vm.hasIncompleteCandle && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.incomplete")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{vm.averageLine !== null && (
|
||||
<Text mt="xs" size="sm">
|
||||
{t("stock.averageLine")}:{" "}
|
||||
{formatMoney(vm.position?.averagePurchasePrice)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Accordion
|
||||
className={classes.priceTable}
|
||||
radius="md"
|
||||
variant="contained"
|
||||
>
|
||||
<Accordion.Item value="prices">
|
||||
<Accordion.Control>
|
||||
<Text fw={600}>{t("stock.priceTable")}</Text>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.priceTableHint", { count: vm.chartData.length })}
|
||||
</Text>
|
||||
</Accordion.Control>
|
||||
|
||||
<Accordion.Panel>
|
||||
<ScrollArea.Autosize
|
||||
mah={360}
|
||||
type="auto"
|
||||
viewportProps={{
|
||||
"aria-label": t("stock.priceTable"),
|
||||
"role": "region",
|
||||
"tabIndex": 0,
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
highlightOnHover
|
||||
horizontalSpacing="md"
|
||||
stickyHeader
|
||||
striped
|
||||
verticalSpacing="sm"
|
||||
>
|
||||
<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>
|
||||
{priceRows.map((candle) => {
|
||||
const displayColor = candle.isComplete
|
||||
? "gray"
|
||||
: "blue";
|
||||
|
||||
return (
|
||||
<Table.Tr key={candle.time}>
|
||||
<Table.Td>{candle.date}</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
{candle.price}
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={displayColor}
|
||||
size="sm"
|
||||
variant="light"
|
||||
>
|
||||
{t(
|
||||
candle.isComplete
|
||||
? "stock.complete"
|
||||
: "stock.forming",
|
||||
)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
}
|
||||
else {
|
||||
chartContent = (
|
||||
<Text c="dimmed">
|
||||
{t(
|
||||
vm.candlesState.partial
|
||||
? "investment.partial"
|
||||
: "stock.noCandles",
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<InstrumentSection
|
||||
controls={(
|
||||
<div className={classes.controls}>
|
||||
<SegmentedControl
|
||||
aria-label={t("stock.period")}
|
||||
data={periodOptions}
|
||||
onChange={vm.setPeriod}
|
||||
value={vm.period}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
large
|
||||
state={vm.candlesState}
|
||||
title={t("stock.chart")}
|
||||
>
|
||||
{chartContent}
|
||||
</InstrumentSection>
|
||||
);
|
||||
};
|
||||
|
||||
export { InstrumentChart };
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatChartValue, formatDecimal, formatMoney } from "@/shared/lib";
|
||||
|
||||
import type { InstrumentChartVm } from "../../model/types";
|
||||
|
||||
type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
|
||||
|
||||
function useInstrumentChartVm(props: InstrumentChartProps) {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const priceRows = vm.priceRows.map((candle) => {
|
||||
const currency = vm.candles?.currency;
|
||||
const value = candle.close;
|
||||
|
||||
const amount = currency ? { currency, value } : undefined;
|
||||
|
||||
const price = props.priceUnit === "percent"
|
||||
? `${formatDecimal(value)} %`
|
||||
: formatMoney(amount);
|
||||
|
||||
return { ...candle, price };
|
||||
});
|
||||
|
||||
const periodOptions = ["month", "halfYear", "year", "all"].map(value => ({
|
||||
label: t(`stock.periods.${value}`),
|
||||
value,
|
||||
}));
|
||||
|
||||
const chartLabel = t(
|
||||
props.priceUnit === "percent" ? "bond.chartLabel" : "stock.chartLabel",
|
||||
{
|
||||
currency:
|
||||
props.priceUnit === "percent"
|
||||
? t("bond.percentOfNominal")
|
||||
: (vm.candles?.currency ?? ""),
|
||||
},
|
||||
);
|
||||
|
||||
const referenceLines
|
||||
= vm.averageLine === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
color: "orange.7",
|
||||
ifOverflow: "extendDomain" as const,
|
||||
label: t("stock.averageLine"),
|
||||
y: vm.averageLine,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
chartLabel,
|
||||
formatChartValue,
|
||||
periodOptions,
|
||||
priceRows,
|
||||
referenceLines,
|
||||
t,
|
||||
vm,
|
||||
};
|
||||
}
|
||||
|
||||
export { useInstrumentChartVm };
|
||||
@@ -20,25 +20,54 @@ type InstrumentEventsProps = {
|
||||
const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options = [
|
||||
"all",
|
||||
"trades",
|
||||
props.incomeFilter ?? "dividends",
|
||||
"other",
|
||||
].map(value => ({
|
||||
label:
|
||||
value === (props.incomeFilter ?? "dividends") && props.incomeLabel
|
||||
? props.incomeLabel
|
||||
: t(`stock.filters.${value}`),
|
||||
value,
|
||||
}));
|
||||
|
||||
let eventsContent;
|
||||
|
||||
if (vm.eventRows.length > 0) {
|
||||
eventsContent = (
|
||||
<Stack gap="sm" mt="md">
|
||||
{vm.eventRows.map(event => (
|
||||
<LedgerEntryRow
|
||||
key={`${event.account?.id}:${event.id}`}
|
||||
operation={event}
|
||||
withDetails
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
else {
|
||||
eventsContent = (
|
||||
<Text c="dimmed" py="lg">
|
||||
{t(
|
||||
vm.operationsState.partial
|
||||
? "investment.partial"
|
||||
: "stock.noOperations",
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InstrumentSection
|
||||
controls={(
|
||||
<div className={classes.controls}>
|
||||
<SegmentedControl
|
||||
aria-label={t("stock.eventFilter")}
|
||||
data={[
|
||||
"all",
|
||||
"trades",
|
||||
props.incomeFilter ?? "dividends",
|
||||
"other",
|
||||
].map(value => ({
|
||||
label:
|
||||
value === (props.incomeFilter ?? "dividends")
|
||||
&& props.incomeLabel
|
||||
? props.incomeLabel
|
||||
: t(`stock.filters.${value}`),
|
||||
value,
|
||||
}))}
|
||||
data={options}
|
||||
onChange={vm.setFilter}
|
||||
value={vm.filter}
|
||||
/>
|
||||
@@ -47,32 +76,14 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
|
||||
state={vm.operationsState}
|
||||
title={t("stock.history")}
|
||||
>
|
||||
{!!(vm.operations) && ((
|
||||
{!!vm.operations && (
|
||||
<>
|
||||
<Text c="dimmed" size="xs">
|
||||
{props.historyNote ?? t("stock.historyNote")}
|
||||
</Text>
|
||||
{vm.eventRows.length > 0
|
||||
? (
|
||||
<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(
|
||||
vm.operationsState.partial
|
||||
? "investment.partial"
|
||||
: "stock.noOperations",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{eventsContent}
|
||||
|
||||
<Group justify="space-between" mt="md">
|
||||
<Button
|
||||
disabled={!vm.hasPrevious || vm.operationsState.refreshing}
|
||||
@@ -81,6 +92,7 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
|
||||
>
|
||||
{t("stock.previous")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabled={!vm.operations.hasNext || vm.operationsState.refreshing}
|
||||
onClick={vm.onNext}
|
||||
@@ -90,7 +102,7 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
))}
|
||||
)}
|
||||
</InstrumentSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,130 +16,148 @@ type InstrumentPositionProps = {
|
||||
returnNote?: string;
|
||||
vm: InstrumentPositionVm;
|
||||
};
|
||||
|
||||
const InstrumentPosition: FC<InstrumentPositionProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
if (vm.position && vm.position.accounts.length === 0) return;
|
||||
|
||||
const positionReturnColor = vm.position?.absoluteReturn?.value.startsWith("-")
|
||||
? "red.8"
|
||||
: "green.8";
|
||||
|
||||
return (
|
||||
<InstrumentSection state={vm.positionState} title={t("stock.position")}>
|
||||
{!!(vm.position) && ((
|
||||
{!!vm.position && (
|
||||
<Stack gap="md">
|
||||
{!!(vm.position.isStale) && ((
|
||||
{!!vm.position.isStale && (
|
||||
<Alert color="orange">{t("portfolio.staleWarning")}</Alert>
|
||||
))}
|
||||
)}
|
||||
|
||||
<div className={`${classes.metrics} ${classes.holding}`}>
|
||||
<div>
|
||||
<Text c="dimmed" size="sm">
|
||||
{props.quantityLabel ?? t("stock.quantity")}
|
||||
</Text>
|
||||
|
||||
<Text className={classes.metricValue}>
|
||||
{formatDecimal(vm.position.quantity)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("stock.value")}
|
||||
</Text>
|
||||
|
||||
<Text className={classes.metricValue}>
|
||||
{formatMoney(vm.position.currentValue)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("stock.average")}
|
||||
</Text>
|
||||
|
||||
<Text className={classes.metricValue}>
|
||||
{formatMoney(vm.position.averagePurchasePrice)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("stock.openReturn")}
|
||||
</Text>
|
||||
<Text
|
||||
c={
|
||||
vm.position.absoluteReturn?.value.startsWith("-")
|
||||
? "red.8"
|
||||
: "green.8"
|
||||
}
|
||||
className={classes.metricValue}
|
||||
>
|
||||
|
||||
<Text c={positionReturnColor} className={classes.metricValue}>
|
||||
{formatMoney(vm.position.absoluteReturn)}
|
||||
</Text>
|
||||
{vm.position.returnPercent !== null && ((
|
||||
|
||||
{vm.position.returnPercent !== null && (
|
||||
<Text size="sm">
|
||||
{formatDecimal(vm.position.returnPercent)}
|
||||
{" "}
|
||||
%
|
||||
{formatDecimal(vm.position.returnPercent)} %
|
||||
</Text>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{props.returnNote ?? 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 c="dimmed" size="xs">
|
||||
{props.quantityLabel ?? t("stock.quantity")}
|
||||
</Text>
|
||||
<Text>{formatDecimal(account.quantity)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.average")}
|
||||
</Text>
|
||||
<Text>{formatMoney(account.averagePurchasePrice)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.value")}
|
||||
</Text>
|
||||
<Text>{formatMoney(account.currentValue)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text c="dimmed" size="xs">
|
||||
{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)}
|
||||
{" "}
|
||||
%
|
||||
{vm.position.accounts.map((account) => {
|
||||
const accountReturnColor = account.absoluteReturn?.value.startsWith("-")
|
||||
? "red.8"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<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 c="dimmed" size="xs">
|
||||
{props.quantityLabel ?? t("stock.quantity")}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Text>{formatDecimal(account.quantity)}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.average")}
|
||||
</Text>
|
||||
|
||||
<Text>{formatMoney(account.averagePurchasePrice)}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.value")}
|
||||
</Text>
|
||||
|
||||
<Text>{formatMoney(account.currentValue)}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.openReturn")}
|
||||
</Text>
|
||||
|
||||
<Text c={accountReturnColor}>
|
||||
{formatMoney(account.absoluteReturn)}
|
||||
</Text>
|
||||
|
||||
{account.returnPercent !== null && (
|
||||
<Text size="xs">
|
||||
{formatDecimal(account.returnPercent)} %
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.snapshotAt", {
|
||||
date: formatSnapshotTime(vm.position.observedAt),
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
)}
|
||||
</InstrumentSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,12 +29,46 @@ type InstrumentSectionProps = PropsWithChildren<{
|
||||
state: SectionState;
|
||||
title: string;
|
||||
}>;
|
||||
|
||||
const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const readError = t(props.state.hasData ? "accounts.refreshError" : "stock.sectionError");
|
||||
|
||||
const readError = t(
|
||||
props.state.hasData ? "accounts.refreshError" : "stock.sectionError",
|
||||
);
|
||||
|
||||
const sectionClassName = props.embedded
|
||||
? classes.embeddedSection
|
||||
: classes.section;
|
||||
|
||||
const skeletonHeight = props.large
|
||||
? CHART_SKELETON_HEIGHT_PX
|
||||
: SECTION_SKELETON_HEIGHT_PX;
|
||||
|
||||
let sectionContent;
|
||||
|
||||
if (props.state.loading) {
|
||||
sectionContent = (
|
||||
<LoadingSkeleton
|
||||
label={t("stock.loadingSection", { section: props.title })}
|
||||
>
|
||||
<Stack>
|
||||
<Skeleton h={skeletonHeight} />
|
||||
|
||||
<Skeleton h={14} w="60%" />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
}
|
||||
else {
|
||||
sectionContent = (
|
||||
props.children
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
className={props.embedded ? classes.embeddedSection : classes.section}
|
||||
className={sectionClassName}
|
||||
component="section"
|
||||
withBorder={!props.embedded}
|
||||
>
|
||||
@@ -42,34 +76,37 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
|
||||
<Title order={2} size="h3">
|
||||
{props.title}
|
||||
</Title>
|
||||
|
||||
{props.controls}
|
||||
</Group>
|
||||
{!!(props.state.stale) && ((
|
||||
<Text size="xs">{t("investment.stale")}</Text>
|
||||
))}
|
||||
{!!(props.state.partial) && ((
|
||||
|
||||
{!!props.state.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
||||
|
||||
{!!props.state.partial && (
|
||||
<Text role="status">{t("investment.partial")}</Text>
|
||||
))}
|
||||
{!!(props.state.lastSuccessfulAt) && ((
|
||||
)}
|
||||
|
||||
{!!props.state.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.state.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
))}
|
||||
{!!(props.state.refreshing) && ((
|
||||
)}
|
||||
|
||||
{!!props.state.refreshing && (
|
||||
<Text c="dimmed" role="status" size="xs">
|
||||
{t("accounts.refreshing")}
|
||||
</Text>
|
||||
))}
|
||||
{!!(props.state.error) && ((
|
||||
)}
|
||||
|
||||
{!!props.state.error && (
|
||||
<Alert aria-live="polite" color="orange" mb="sm" role="status">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">
|
||||
{props.state.sourceError
|
||||
? t("investment.syncFailed")
|
||||
: readError}
|
||||
{props.state.sourceError ? t("investment.syncFailed") : readError}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
disabled={props.state.refreshing}
|
||||
onClick={props.state.retry}
|
||||
@@ -80,27 +117,9 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
))}
|
||||
{props.state.loading
|
||||
? (
|
||||
<LoadingSkeleton
|
||||
label={t("stock.loadingSection", { section: props.title })}
|
||||
>
|
||||
<Stack>
|
||||
<Skeleton
|
||||
h={
|
||||
props.large
|
||||
? CHART_SKELETON_HEIGHT_PX
|
||||
: SECTION_SKELETON_HEIGHT_PX
|
||||
}
|
||||
/>
|
||||
<Skeleton h={14} w="60%" />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
)
|
||||
: (
|
||||
props.children
|
||||
)}
|
||||
)}
|
||||
|
||||
{sectionContent}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user