Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a89b08ee5 | ||
|
|
2844b22dba | ||
|
|
ff67a768ad | ||
|
|
10baefda79 | ||
|
|
f5f4447d60 | ||
|
|
0bc8bc36b7 |
@@ -17,6 +17,10 @@
|
||||
CSF сохраняет default meta и прямые story exports; configuration exceptions
|
||||
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
|
||||
|
||||
Тип значений формы выводится из её schema, когда input/output совпадают.
|
||||
Общие props определяются у одного owner; DTO и presentation model сохраняют
|
||||
собственные типы при смысловом отличии. Type re-export сохраняет public imports.
|
||||
|
||||
## Читаемость: ESLint и review
|
||||
|
||||
ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между
|
||||
@@ -33,6 +37,10 @@ CSS и Markdown также держи в 80 колонках там, где эт
|
||||
|
||||
Review проверяет то, чего синтаксис не доказывает:
|
||||
|
||||
Обоснованное config exception сопровождается причиной и явным ожиданием в
|
||||
existing lint tests, включая разрешённый и запрещённый примеры. Это позволяет
|
||||
настраивать профиль без случайного ослабления остальных правил.
|
||||
|
||||
- Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат.
|
||||
- Имена derived values описывают значение, например `projectedAmount`.
|
||||
- Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток
|
||||
|
||||
@@ -63,6 +63,12 @@ Presentation VM для form-context/UI-only state может использов
|
||||
в каталоге: её граф зависимостей не достигает application data/API.
|
||||
`useTranslation` и декларативный вывод коллекции допустимы во view.
|
||||
|
||||
ESLint ограничивает прямые data/auth/router hook imports в `*.view.tsx`;
|
||||
type imports, error helpers, router components и form context разрешены.
|
||||
Транзитивный fetching через local presentation hook проверяется review;
|
||||
Storybook дополнительно проверяет reachable graph. Новое исключение должно
|
||||
сохранять presentation-only responsibility.
|
||||
|
||||
JSX props получают ссылки на функции, включая `onClick`, render props,
|
||||
`dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай
|
||||
`useController` в VM вместо inline `Controller.render`. Значение prop с условием
|
||||
|
||||
@@ -185,6 +185,44 @@ export default defineConfig(
|
||||
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
|
||||
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"],
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.view.tsx"],
|
||||
rules: {
|
||||
"no-restricted-imports": ["error", {
|
||||
paths: [
|
||||
{
|
||||
allowTypeImports: true,
|
||||
message: "Query lifecycle belongs to the data VM.",
|
||||
name: "@tanstack/react-query",
|
||||
},
|
||||
{
|
||||
allowTypeImports: true,
|
||||
message: "Transport belongs to shared/api and the data VM.",
|
||||
name: "openapi-fetch",
|
||||
},
|
||||
{
|
||||
allowTypeImports: true,
|
||||
message: "Transport belongs to shared/api and the data VM.",
|
||||
name: "openapi-react-query",
|
||||
},
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
allowTypeImports: true,
|
||||
group: ["react-router", "@/entities/*", "@/features/*"],
|
||||
importNamePattern: "^use",
|
||||
message: "Pass route, auth and entity data from the data VM.",
|
||||
},
|
||||
{
|
||||
allowTypeImports: true,
|
||||
group: ["@/shared/api", "@/shared/api/*"],
|
||||
importNamePattern: "^use|^api(?:Query)?Client$",
|
||||
message: "Fetching belongs to the data VM; error helpers are allowed.",
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
||||
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
||||
|
||||
Generated
+21
-21
@@ -4082,9 +4082,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
|
||||
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6942,9 +6942,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/basic-ftp": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
|
||||
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.2.1.tgz",
|
||||
"integrity": "sha512-bK67isD+lKq46AU8vNtjvMaT2ZqAOAmNCbxUHlFBRD4k15NWxyEjmaKtZPlgce58So4BNTjITGQOVTjL9y0ECA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -6969,9 +6969,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9668,9 +9668,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -10702,9 +10702,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
|
||||
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
|
||||
"version": "10.7.2",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14291,9 +14291,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/release-it/node_modules/undici": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
|
||||
"integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14673,9 +14673,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/serialize-javascript": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.1.tgz",
|
||||
"integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==",
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz",
|
||||
"integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
|
||||
@@ -114,6 +114,12 @@
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vitest": "5.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"release-it": {
|
||||
"basic-ftp": "6.2.1",
|
||||
"undici": "7.29.1"
|
||||
}
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.2": true,
|
||||
"fsevents@2.3.3": true
|
||||
|
||||
@@ -338,6 +338,46 @@ test("JSX spacing fixes preserve text interpolation and are stable", async () =>
|
||||
assert.equal(again.errorCount, 0);
|
||||
});
|
||||
|
||||
test("views accept presentation inputs but reject direct data hooks", async () => {
|
||||
const eslint = new ESLint();
|
||||
const filePath = "src/features/instrument-details/ui/InstrumentEvents.view.tsx";
|
||||
|
||||
for (const code of [
|
||||
"import { useStock } from \"@/entities/stock\";",
|
||||
"import { useAuth } from \"@/features/auth\";",
|
||||
"import { useNavigate } from \"react-router\";",
|
||||
"import { apiClient } from \"@/shared/api\";",
|
||||
"import { useQuery } from \"@tanstack/react-query\";",
|
||||
]) {
|
||||
const [result] = await eslint.lintText(code, { filePath });
|
||||
assert.equal(result.fatalErrorCount, 0);
|
||||
assert.ok(result.messages.some(message =>
|
||||
message.ruleId === "no-restricted-imports"));
|
||||
}
|
||||
|
||||
for (const code of [
|
||||
"import { Link } from \"react-router\";",
|
||||
"import { apiErrorCode } from \"@/shared/api\";",
|
||||
"import type { StockPosition } from \"@/entities/stock\";",
|
||||
"import type { QueryKey } from \"@tanstack/react-query\";",
|
||||
"import { useTranslation } from \"react-i18next\";",
|
||||
"import { useFormContext } from \"react-hook-form\";",
|
||||
]) {
|
||||
const [result] = await eslint.lintText(code, { filePath });
|
||||
assert.equal(result.fatalErrorCount, 0);
|
||||
assert.ok(result.messages.every(message =>
|
||||
message.ruleId !== "no-restricted-imports"));
|
||||
}
|
||||
|
||||
const [vm] = await eslint.lintText(
|
||||
"import { useStock } from \"@/entities/stock\";",
|
||||
{ filePath: "src/pages/stock/ui/StockPage/StockPage.vm.ts" },
|
||||
);
|
||||
|
||||
assert.ok(vm.messages.every(message =>
|
||||
message.ruleId !== "no-restricted-imports"));
|
||||
});
|
||||
|
||||
test("compact JSX and contract nulls need no local suppressions", async () => {
|
||||
const eslint = new ESLint({ fix: true });
|
||||
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
||||
|
||||
@@ -5,6 +5,8 @@ import { validationMessage } from "@/shared/i18n";
|
||||
import type { ExpenseCategory } from "../api/types";
|
||||
const MAX_CATEGORY_NAME_LENGTH = 80;
|
||||
|
||||
type CategoryValues = z.infer<ReturnType<typeof categorySchema>>;
|
||||
|
||||
function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
|
||||
return z.object({
|
||||
name: z
|
||||
@@ -27,4 +29,4 @@ function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export { categorySchema };
|
||||
export { categorySchema, type CategoryValues };
|
||||
|
||||
@@ -12,16 +12,14 @@ import { type FC } from "react";
|
||||
import { FormProvider, useController, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CategoryValues } from "../../model/category.schema";
|
||||
|
||||
import { bankOperationAppearance } from "../../model/operation-appearance";
|
||||
import { BankFeedback } from "../BankFeedback";
|
||||
import { useCategoryManagerVm } from "./CategoryManager.vm";
|
||||
|
||||
type CategoryManagerProps = {
|
||||
onClose: () => void;
|
||||
onCreated?: (id: string) => void;
|
||||
};
|
||||
|
||||
type CategoryValues = { name: string };
|
||||
import {
|
||||
type CategoryManagerProps,
|
||||
useCategoryManagerVm,
|
||||
} from "./CategoryManager.vm";
|
||||
|
||||
const CategoryFields: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
useUpdateExpenseCategory,
|
||||
} from "../../api/mutations";
|
||||
import { useBankCategories } from "../../api/queries";
|
||||
import { categorySchema } from "../../model/category.schema";
|
||||
import { categorySchema, type CategoryValues } from "../../model/category.schema";
|
||||
import { useBankFormFocus } from "../useBankFormFocus";
|
||||
|
||||
type CategoryManagerProps = {
|
||||
@@ -20,8 +20,6 @@ type CategoryManagerProps = {
|
||||
onCreated?: (id: string) => void;
|
||||
};
|
||||
|
||||
type CategoryValues = { name: string };
|
||||
|
||||
function useCategoryManagerVm(props: CategoryManagerProps) {
|
||||
useBankFormFocus();
|
||||
const [opened, setOpened] = useState(true);
|
||||
@@ -43,6 +41,13 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
|
||||
const isPending = !opened || methods.formState.isSubmitting;
|
||||
const onRequestClose = () => !isPending && setOpened(false);
|
||||
|
||||
const finishEditing = () => {
|
||||
editSessionRef.current++;
|
||||
setEditing(undefined);
|
||||
setLatest(undefined);
|
||||
methods.reset({ name: "" });
|
||||
};
|
||||
|
||||
const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => {
|
||||
void methods.handleSubmit(async (values) => {
|
||||
setError(undefined);
|
||||
@@ -61,10 +66,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
|
||||
if (result) props.onCreated?.(result.id);
|
||||
}
|
||||
|
||||
editSessionRef.current++;
|
||||
setEditing(undefined);
|
||||
setLatest(undefined);
|
||||
methods.reset({ name: "" });
|
||||
finishEditing();
|
||||
}
|
||||
catch (error_) {
|
||||
setError(error_);
|
||||
@@ -90,10 +92,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
editSessionRef.current++;
|
||||
setEditing(undefined);
|
||||
setLatest(undefined);
|
||||
methods.reset({ name: "" });
|
||||
finishEditing();
|
||||
setError(undefined);
|
||||
};
|
||||
|
||||
@@ -131,4 +130,4 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
|
||||
};
|
||||
}
|
||||
|
||||
export { useCategoryManagerVm };
|
||||
export { type CategoryManagerProps, useCategoryManagerVm };
|
||||
|
||||
@@ -5,7 +5,8 @@ import { WalletIcon } from "@wealthledger/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
import { formatDate, formatMoney } from "@/shared/lib";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { BrokerageAccountCard } from "../../model/display";
|
||||
|
||||
@@ -53,6 +54,39 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
type BrokerageAccountSourceStatusProps = {
|
||||
source: BrokerageAccountCard["source"];
|
||||
};
|
||||
|
||||
const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
|
||||
props,
|
||||
) => {
|
||||
const { source } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isSyncing
|
||||
= source?.sync.state === "running" || source?.sync.state === "queued";
|
||||
|
||||
const errorText
|
||||
= source?.sync.state === "failed" ? t("accounts.refreshError") : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SourceSyncStatus
|
||||
errorText={errorText}
|
||||
lastSuccessfulAt={source?.lastSuccessfulAt}
|
||||
stale={source?.freshness === "stale"}
|
||||
/>
|
||||
|
||||
{isSyncing && (
|
||||
<Text role="status" size="xs">
|
||||
{t("accounts.refreshing")}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
||||
const { accounts } = props;
|
||||
const { t } = useTranslation();
|
||||
@@ -90,24 +124,7 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
||||
|
||||
<BrokerageAccountSummary summary={summary} />
|
||||
|
||||
{!!source?.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(source.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{(source?.sync.state === "running"
|
||||
|| source?.sync.state === "queued") && (
|
||||
<Text role="status" size="xs">
|
||||
{t("accounts.refreshing")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{source?.sync.state === "failed" && <Text size="xs">{t("accounts.refreshError")}</Text>}
|
||||
|
||||
{source?.freshness === "stale" && <Text size="xs">{t("investment.stale")}</Text>}
|
||||
<BrokerageAccountSourceStatus source={source} />
|
||||
</Stack>
|
||||
|
||||
{!!positionCounts && (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
|
||||
|
||||
@@ -33,22 +33,19 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
||||
const { events } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const errorText
|
||||
= events.state === "loaded" && events.refreshError
|
||||
? t("accounts.refreshError")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{events.state === "loaded" && (
|
||||
<>
|
||||
{!!events.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(events.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!events.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
||||
|
||||
{!!events.refreshError && <Text role="status">{t("accounts.refreshError")}</Text>}
|
||||
</>
|
||||
<SourceSyncStatus
|
||||
errorText={errorText}
|
||||
lastSuccessfulAt={events.lastSuccessfulAt}
|
||||
stale={events.stale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{events.state === "loading" && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { toInstrumentEventRows } from "./model/operation-rows";
|
||||
export { section, type SectionState } from "./model/section";
|
||||
export { useInstrumentParameters } from "./model/useInstrumentParameters";
|
||||
export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view";
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||
|
||||
import { toInstrumentEventRows } from "./operation-rows";
|
||||
|
||||
const operation: BrokerageOperation = {
|
||||
accruedInterest: null,
|
||||
category: "trade",
|
||||
commission: null,
|
||||
description: "Покупка",
|
||||
figi: null,
|
||||
id: "first",
|
||||
instrumentType: "stock",
|
||||
instrumentUid: "uid",
|
||||
logoName: null,
|
||||
occurredAt: "2026-01-01T00:00:00Z",
|
||||
payment: null,
|
||||
price: null,
|
||||
quantity: null,
|
||||
ticker: null,
|
||||
type: "OPERATION_TYPE_BUY",
|
||||
};
|
||||
|
||||
it("fills only missing identity without changing rows or their order", () => {
|
||||
const items = [operation, {
|
||||
...operation,
|
||||
id: "second",
|
||||
logoName: "",
|
||||
ticker: "own",
|
||||
}];
|
||||
|
||||
const original = structuredClone(items);
|
||||
expect(toInstrumentEventRows(items, {
|
||||
logoName: "fallback.png",
|
||||
ticker: "fallback",
|
||||
})).toEqual([
|
||||
{ ...operation, logoName: "fallback.png", ticker: "fallback" },
|
||||
items[1],
|
||||
]);
|
||||
expect(items).toEqual(original);
|
||||
expect(toInstrumentEventRows(items)).toEqual(items);
|
||||
expect(toInstrumentEventRows()).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||
|
||||
function toInstrumentEventRows(
|
||||
items: BrokerageOperation[] = [],
|
||||
instrument?: Pick<BrokerageOperation, "logoName" | "ticker">,
|
||||
): BrokerageOperation[] {
|
||||
return items.map(item => ({
|
||||
...item,
|
||||
logoName: item.logoName ?? instrument?.logoName ?? null,
|
||||
ticker: item.ticker ?? instrument?.ticker ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export { toInstrumentEventRows };
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { SectionState } from "../model/section";
|
||||
|
||||
@@ -80,18 +80,13 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
|
||||
{props.controls}
|
||||
</Group>
|
||||
|
||||
{!!props.state.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
||||
<SourceSyncStatus
|
||||
lastSuccessfulAt={props.state.lastSuccessfulAt}
|
||||
stale={props.state.stale}
|
||||
/>
|
||||
|
||||
{!!props.state.partial && <Text role="status">{t("investment.partial")}</Text>}
|
||||
|
||||
{!!props.state.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.state.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!props.state.refreshing && (
|
||||
<Text c="dimmed" role="status" size="xs">
|
||||
{t("accounts.refreshing")}
|
||||
|
||||
+2
-8
@@ -3,7 +3,7 @@ import type { FC } from "react";
|
||||
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
type InvestmentRefreshStateProps = {
|
||||
labelScope?: "accounts" | "portfolio";
|
||||
@@ -20,13 +20,7 @@ const InvestmentRefreshState: FC<InvestmentRefreshStateProps> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!props.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
|
||||
|
||||
<Button
|
||||
disabled={refreshing}
|
||||
|
||||
+2
-8
@@ -16,7 +16,7 @@ import {
|
||||
BrokerageEventCard,
|
||||
brokerageEventKey,
|
||||
} from "@/entities/brokerage-event";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
||||
|
||||
@@ -55,13 +55,7 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{!!props.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
|
||||
|
||||
<Title order={1}>{t("events.title")}</Title>
|
||||
|
||||
|
||||
+2
-8
@@ -13,7 +13,7 @@ import { StateView } from "@wealthledger/ui";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
|
||||
@@ -47,13 +47,7 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Stack className={classes.page} gap="lg">
|
||||
{!!props.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
|
||||
|
||||
<Title order={1}>{t("income.title")}</Title>
|
||||
|
||||
|
||||
+2
-9
@@ -7,7 +7,6 @@ import {
|
||||
MultiSelect,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
@@ -15,7 +14,7 @@ import { StateView } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
|
||||
|
||||
@@ -63,13 +62,7 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{!!props.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
|
||||
|
||||
<Title order={1}>{t("ledger.title")}</Title>
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Link } from "react-router";
|
||||
|
||||
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
|
||||
|
||||
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
type IncomeSummaryProps
|
||||
= | {
|
||||
@@ -25,6 +26,12 @@ type IncomeSummaryProps
|
||||
|
||||
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const errorText
|
||||
= props.state === "loaded" && props.refreshError
|
||||
? t("income.refreshError")
|
||||
: null;
|
||||
|
||||
if (props.state === "loading" || props.state === "partial")
|
||||
return (
|
||||
<Text c="dimmed" role="status">
|
||||
@@ -59,17 +66,11 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{!!props.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!props.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
||||
|
||||
{!!props.refreshError && <Text role="status">{t("income.refreshError")}</Text>}
|
||||
<SourceSyncStatus
|
||||
errorText={errorText}
|
||||
lastSuccessfulAt={props.lastSuccessfulAt}
|
||||
stale={props.stale}
|
||||
/>
|
||||
|
||||
{props.summary.totalsByCurrency.map(total => (
|
||||
<Paper key={total.currency} p="sm" withBorder>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { AccountPageRecentOperations } from "../../../model/previews";
|
||||
|
||||
@@ -18,22 +18,19 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const { operations } = props;
|
||||
|
||||
const errorText
|
||||
= operations.state === "loaded" && operations.refreshError
|
||||
? t("accounts.refreshError")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{operations.state === "loaded" && (
|
||||
<>
|
||||
{!!operations.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(operations.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!operations.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
||||
|
||||
{!!operations.refreshError && <Text role="status">{t("accounts.refreshError")}</Text>}
|
||||
</>
|
||||
<SourceSyncStatus
|
||||
errorText={errorText}
|
||||
lastSuccessfulAt={operations.lastSuccessfulAt}
|
||||
stale={operations.stale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{operations.state === "loading" && (
|
||||
|
||||
@@ -203,6 +203,37 @@ describe("AccountsPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["idle", "failed"] as const)(
|
||||
"keeps partial empty membership distinct from no accounts (sync=%s)",
|
||||
async (state) => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () =>
|
||||
HttpResponse.json(resource([], {
|
||||
availability: "partial",
|
||||
sync: { errorCode: null, id: null, state },
|
||||
})),
|
||||
),
|
||||
);
|
||||
const client = createTestQueryClient();
|
||||
renderPage(client);
|
||||
await waitFor(() =>
|
||||
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({
|
||||
data: [],
|
||||
meta: { availability: "partial" },
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByText("Нет инвестиционных счетов"))
|
||||
.not.toBeInTheDocument();
|
||||
if (state === "failed")
|
||||
// eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract.
|
||||
expect(await screen.findByRole("button", { name: "Повторить" }))
|
||||
.toBeVisible();
|
||||
else
|
||||
// eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract.
|
||||
expect(screen.getByRole("status")).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps unknown membership loading instead of declaring no accounts", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () =>
|
||||
|
||||
@@ -15,31 +15,31 @@ function useAccountsPageViewModel() {
|
||||
) {
|
||||
if (!accounts.isError && accounts.meta?.sync.state !== "failed")
|
||||
return { state: "loading" as const };
|
||||
if (accounts.isError || accounts.meta?.sync.state === "failed")
|
||||
return {
|
||||
code: apiErrorCode(accounts.error),
|
||||
retry: sync.retry,
|
||||
state: "error" as const,
|
||||
};
|
||||
|
||||
return {
|
||||
code: apiErrorCode(accounts.error),
|
||||
retry: sync.retry,
|
||||
state: "error" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (!accounts.data?.length)
|
||||
return {
|
||||
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
|
||||
refreshError: accounts.isError || sync.transportError || sync.sourceError,
|
||||
refreshing: accounts.isFetching || sync.refreshing,
|
||||
retry: sync.retry,
|
||||
sourceError: sync.sourceError,
|
||||
state: "empty" as const,
|
||||
};
|
||||
|
||||
return {
|
||||
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
|
||||
const refresh = {
|
||||
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
|
||||
refreshError: accounts.isError || sync.transportError || sync.sourceError,
|
||||
refreshing: accounts.isFetching || sync.refreshing,
|
||||
retry: sync.retry,
|
||||
sourceError: sync.sourceError,
|
||||
};
|
||||
|
||||
if (accounts.data.length === 0)
|
||||
return {
|
||||
...refresh,
|
||||
state: "empty" as const,
|
||||
};
|
||||
|
||||
return {
|
||||
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
|
||||
...refresh,
|
||||
state: "loaded" as const,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,10 +9,14 @@ import {
|
||||
useBondQuote,
|
||||
} from "@/entities/bond";
|
||||
import { useBrokerageOperationHistory } from "@/entities/broker-operation";
|
||||
import { section, useInstrumentParameters } from "@/features/instrument-details";
|
||||
import {
|
||||
section,
|
||||
toInstrumentEventRows,
|
||||
useInstrumentParameters,
|
||||
} from "@/features/instrument-details";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { instrumentLogoUrl } from "@/shared/lib";
|
||||
import { compareDateDesc, instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
type BondPageVm = ReturnType<typeof useBondPageVm>;
|
||||
|
||||
@@ -62,20 +66,7 @@ function useBondPageVm() {
|
||||
code: apiErrorCode(bond.error),
|
||||
coupons: coupons.data,
|
||||
couponsState: section(coupons, sync.retry),
|
||||
eventRows:
|
||||
operations.data?.items.map(item => ({
|
||||
...item,
|
||||
logoName:
|
||||
item.logoName
|
||||
?? bond.data?.logoName
|
||||
|
||||
?? null,
|
||||
ticker:
|
||||
item.ticker
|
||||
?? bond.data?.ticker
|
||||
|
||||
?? null,
|
||||
})) ?? [],
|
||||
eventRows: toInstrumentEventRows(operations.data?.items, bond.data),
|
||||
filter,
|
||||
hasPrevious: history.hasPrevious,
|
||||
logoSrc: instrumentLogoUrl(bond.data?.logoName),
|
||||
@@ -92,9 +83,7 @@ function useBondPageVm() {
|
||||
coupons.data?.coupons
|
||||
.filter(event => event.timing !== "upcoming")
|
||||
.toSorted((a, b) =>
|
||||
(b.paymentDate ?? b.recordDate ?? "").localeCompare(
|
||||
a.paymentDate ?? a.recordDate ?? "",
|
||||
),
|
||||
compareDateDesc(a.paymentDate, b.paymentDate),
|
||||
) ?? [],
|
||||
period,
|
||||
position: position.data,
|
||||
@@ -110,9 +99,7 @@ function useBondPageVm() {
|
||||
coupons.data?.coupons
|
||||
.filter(event => event.timing === "upcoming")
|
||||
.toSorted((a, b) =>
|
||||
(a.paymentDate ?? a.recordDate ?? "").localeCompare(
|
||||
b.paymentDate ?? b.recordDate ?? "",
|
||||
),
|
||||
compareDateDesc(b.paymentDate, a.paymentDate),
|
||||
) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { BankCard } from "@/entities/bank-card";
|
||||
|
||||
import type { OperationValues } from "./OperationForm.types";
|
||||
|
||||
import { createOperationSchema } from "./OperationForm.schema";
|
||||
|
||||
const card: BankCard = {
|
||||
archivedAt: null,
|
||||
balance: { currency: "RUB", value: "0" },
|
||||
bankName: null,
|
||||
id: "card",
|
||||
lastFour: null,
|
||||
name: "Карта",
|
||||
openingBalance: { currency: "RUB", value: "0" },
|
||||
openingDate: "2026-01-01",
|
||||
paymentSystem: null,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const values: OperationValues = {
|
||||
amount: "1",
|
||||
cardId: card.id,
|
||||
categoryId: "",
|
||||
date: "2026-01-02",
|
||||
description: "",
|
||||
kind: "expense",
|
||||
originalExpenseId: "",
|
||||
targetCardId: "",
|
||||
};
|
||||
|
||||
describe("expense category validation", () => {
|
||||
it.each([
|
||||
["", [{ id: "default", isDefault: true }], true],
|
||||
["chosen", [{ id: "chosen", isDefault: false }], true],
|
||||
["", [{ id: "chosen", isDefault: false }], false],
|
||||
["unknown", [{ id: "default", isDefault: true }], false],
|
||||
["", undefined, false],
|
||||
] as const)("checks category %s with current categories %j", (
|
||||
categoryId, categories, valid,
|
||||
) => {
|
||||
const schema = createOperationSchema({
|
||||
cards: [card],
|
||||
categories: categories ? [...categories] : undefined,
|
||||
});
|
||||
|
||||
const result = schema.safeParse({ ...values, categoryId });
|
||||
expect(result.success).toBe(valid);
|
||||
if (!result.success)
|
||||
// eslint-disable-next-line vitest/no-conditional-expect -- Invalid table cases retain the category field error boundary.
|
||||
expect(result.error.issues.map(issue => issue.path))
|
||||
.toEqual([["categoryId"]]);
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
} from "@/entities/bank-card";
|
||||
import { validationMessage } from "@/shared/i18n";
|
||||
|
||||
import type { OperationValues } from "./OperationForm.types";
|
||||
|
||||
// ZodIssueCode is deprecated; this is the custom refinement code accepted by Zod.
|
||||
enum FormIssueCode {
|
||||
Custom = "custom",
|
||||
@@ -43,6 +41,8 @@ const operationSchema = z.object({
|
||||
targetCardId: z.string(),
|
||||
});
|
||||
|
||||
type OperationValues = z.infer<typeof operationSchema>;
|
||||
|
||||
function createOperationSchema({
|
||||
cards,
|
||||
categories,
|
||||
@@ -83,16 +83,19 @@ function createOperationSchema({
|
||||
path: ["date"],
|
||||
...validationMessage("bank.validation.start")(undefined),
|
||||
});
|
||||
if (values.kind === "refund")
|
||||
|
||||
if (values.kind === "refund") {
|
||||
validateRefund(values, context, expense, limit);
|
||||
if (
|
||||
values.kind === "expense"
|
||||
&& !categories?.some(
|
||||
c =>
|
||||
c.id
|
||||
=== (values.categoryId || categories.find(c => c.isDefault)?.id),
|
||||
)
|
||||
)
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.kind !== "expense") return;
|
||||
|
||||
const effectiveCategoryId
|
||||
= values.categoryId || categories?.find(c => c.isDefault)?.id;
|
||||
|
||||
if (!categories?.some(c => c.id === effectiveCategoryId))
|
||||
context.addIssue({
|
||||
code: FormIssueCode.Custom,
|
||||
path: ["categoryId"],
|
||||
@@ -128,4 +131,4 @@ function validateRefund(
|
||||
});
|
||||
}
|
||||
|
||||
export { createOperationSchema };
|
||||
export { createOperationSchema, type OperationValues };
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
BankOperation,
|
||||
} from "@/entities/bank-card";
|
||||
|
||||
import type { OperationValues } from "./OperationForm.schema";
|
||||
|
||||
type Choice = { label: string; value: string };
|
||||
|
||||
type OperationFieldsProps = {
|
||||
@@ -26,20 +28,11 @@ type OperationFormProps = {
|
||||
operation?: BankOperation;
|
||||
};
|
||||
|
||||
type OperationValues = {
|
||||
amount: string;
|
||||
cardId: string;
|
||||
categoryId: string;
|
||||
date: string;
|
||||
description: string;
|
||||
kind: BankOperation["kind"];
|
||||
originalExpenseId: string;
|
||||
targetCardId: string;
|
||||
};
|
||||
|
||||
export type {
|
||||
Choice,
|
||||
OperationFieldsProps,
|
||||
OperationFormProps,
|
||||
OperationValues,
|
||||
|
||||
};
|
||||
|
||||
export { type OperationValues } from "./OperationForm.schema";
|
||||
|
||||
@@ -43,6 +43,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
const [submittedValues, setSubmittedValues] = useState<OperationValues>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const categories = useBankCategories();
|
||||
const defaultCategoryId = categories.data?.find(c => c.isDefault)?.id;
|
||||
const operationCreation = useCreateBankOperation();
|
||||
const updateOperation = useUpdateBankOperation();
|
||||
|
||||
@@ -98,7 +99,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
}),
|
||||
...(values.kind === "expense" && {
|
||||
categoryId:
|
||||
values.categoryId || categories.data?.find(c => c.isDefault)?.id,
|
||||
values.categoryId || defaultCategoryId,
|
||||
}),
|
||||
...(values.kind === "refund" && {
|
||||
originalExpenseId: values.originalExpenseId,
|
||||
@@ -169,7 +170,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
cards: options,
|
||||
categories:
|
||||
categories.data?.map(c => ({ label: c.name, value: c.id })) ?? [],
|
||||
defaultCategory: categories.data?.find(c => c.isDefault)?.id ?? "",
|
||||
defaultCategory: defaultCategoryId ?? "",
|
||||
editing: Boolean(props.operation),
|
||||
kind,
|
||||
kinds: bankOperationKinds.map(value => ({
|
||||
|
||||
@@ -11,11 +11,12 @@ import {
|
||||
} from "@/entities/stock";
|
||||
import {
|
||||
section,
|
||||
toInstrumentEventRows,
|
||||
useInstrumentParameters,
|
||||
} from "@/features/instrument-details";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { instrumentLogoUrl } from "@/shared/lib";
|
||||
import { compareDateDesc, instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
type StockPageVm = ReturnType<typeof useStockPageVm>;
|
||||
|
||||
@@ -65,20 +66,7 @@ function useStockPageVm() {
|
||||
companyState: section(company, sync.retry),
|
||||
dividends: dividends.data,
|
||||
dividendsState: section(dividends, sync.retry),
|
||||
eventRows:
|
||||
operations.data?.items.map(item => ({
|
||||
...item,
|
||||
logoName:
|
||||
item.logoName
|
||||
?? stock.data?.logoName
|
||||
|
||||
?? null,
|
||||
ticker:
|
||||
item.ticker
|
||||
?? stock.data?.ticker
|
||||
|
||||
?? null,
|
||||
})) ?? [],
|
||||
eventRows: toInstrumentEventRows(operations.data?.items, stock.data),
|
||||
filter,
|
||||
hasPrevious: history.hasPrevious,
|
||||
logoSrc: instrumentLogoUrl(stock.data?.logoName),
|
||||
@@ -93,9 +81,7 @@ function useStockPageVm() {
|
||||
dividends.data?.dividends
|
||||
.filter(event => event.timing !== "upcoming")
|
||||
.toSorted((a, b) =>
|
||||
(b.recordDate ?? b.paymentDate ?? "").localeCompare(
|
||||
a.recordDate ?? a.paymentDate ?? "",
|
||||
),
|
||||
compareDateDesc(a.paymentDate, b.paymentDate),
|
||||
) ?? [],
|
||||
period,
|
||||
position: position.data,
|
||||
@@ -113,9 +99,7 @@ function useStockPageVm() {
|
||||
dividends.data?.dividends
|
||||
.filter(event => event.timing === "upcoming")
|
||||
.toSorted((a, b) =>
|
||||
(a.recordDate ?? a.paymentDate ?? "").localeCompare(
|
||||
b.recordDate ?? b.paymentDate ?? "",
|
||||
),
|
||||
compareDateDesc(b.paymentDate, a.paymentDate),
|
||||
) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isValidPeriod, moscowDate } from "./calendar";
|
||||
import { compareDateDesc, isValidPeriod, moscowDate } from "./calendar";
|
||||
import { calendarDate } from "./calendar.schema";
|
||||
|
||||
describe("calendar dates", () => {
|
||||
@@ -32,4 +32,22 @@ describe("calendar dates", () => {
|
||||
expect(moscowDate(new Date("2026-12-31T20:59:59Z"))).toBe("2026-12-31");
|
||||
expect(moscowDate(new Date("2026-12-31T21:00:00Z"))).toBe("2027-01-01");
|
||||
});
|
||||
|
||||
it("compares dates newest first and keeps dateless rows last", () => {
|
||||
const dates = [null, "2026-01-01", "2026-03-01", "2026-02-01"];
|
||||
|
||||
expect(dates.toSorted(compareDateDesc)).toEqual([
|
||||
"2026-03-01",
|
||||
"2026-02-01",
|
||||
"2026-01-01",
|
||||
null,
|
||||
]);
|
||||
|
||||
expect(dates.toSorted((a, b) => compareDateDesc(b, a))).toEqual([
|
||||
null,
|
||||
"2026-01-01",
|
||||
"2026-02-01",
|
||||
"2026-03-01",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,12 @@ const moscowFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
// Newest first. Missing dates sort last in both directions, so a row without a
|
||||
// date never jumps to the top of a "past" list.
|
||||
function compareDateDesc(a: null | string, b: null | string) {
|
||||
return (b ?? "").localeCompare(a ?? "");
|
||||
}
|
||||
|
||||
function moscowDate(value: Date): string {
|
||||
const parts = moscowFormatter.formatToParts(value);
|
||||
|
||||
@@ -25,4 +31,4 @@ function moscowDate(value: Date): string {
|
||||
return `${part("year")}-${part("month")}-${part("day")}`;
|
||||
}
|
||||
|
||||
export { isValidPeriod, moscowDate };
|
||||
export { compareDateDesc, isValidPeriod, moscowDate };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { isValidPeriod, moscowDate } from "./calendar";
|
||||
export { compareDateDesc, isValidPeriod, moscowDate } from "./calendar";
|
||||
export { calendarDate } from "./calendar.schema";
|
||||
export {
|
||||
formatChartValue,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SourceSyncStatus, type SourceSyncStatusProps } from "./SourceSyncStatus";
|
||||
|
||||
function renderStatus(props: SourceSyncStatusProps) {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<SourceSyncStatus {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SourceSyncStatus", () => {
|
||||
it("renders nothing without source data", () => {
|
||||
renderStatus({});
|
||||
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Данные/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the source timestamp", () => {
|
||||
renderStatus({ lastSuccessfulAt: "2026-02-03T10:15:00.000Z" });
|
||||
|
||||
expect(screen.getByText(/Данные на/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("announces the caller-provided error text as a status", () => {
|
||||
renderStatus({ errorText: "Обновление не удалось" });
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Обновление не удалось");
|
||||
});
|
||||
|
||||
it("keeps the notices next to the timestamp they belong to", () => {
|
||||
renderStatus({
|
||||
errorText: "Обновление не удалось",
|
||||
lastSuccessfulAt: "2026-02-03T10:15:00.000Z",
|
||||
stale: true,
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Данные на/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Данные требуют обновления")).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Обновление не удалось");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
|
||||
// Owns the "when did the source last answer" line and the two notices that
|
||||
// always travel with it. Surfaces keep their own busy indicator: a list card
|
||||
// shows a muted line, a page header a progress bar — that is a product call.
|
||||
type SourceSyncStatusProps = {
|
||||
errorText?: null | string;
|
||||
lastSuccessfulAt?: null | string;
|
||||
stale?: boolean;
|
||||
};
|
||||
|
||||
const SourceSyncStatus: FC<SourceSyncStatusProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!props.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!props.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
||||
|
||||
{!!props.errorText && <Text role="status">{props.errorText}</Text>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { SourceSyncStatus, type SourceSyncStatusProps };
|
||||
@@ -0,0 +1 @@
|
||||
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";
|
||||
Reference in New Issue
Block a user