From 94adbafa344b23d7f95c31db975d99d990bfcb8b Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 20:23:02 +0300 Subject: [PATCH 01/16] refactor: simplify calendar and form state --- package.json | 2 +- .../bank-card/model/form-validation.test.ts | 3 + .../bank-card/model/form-validation.ts | 9 +- src/entities/bank-card/model/form.schema.ts | 7 +- .../account-income/model/display.test.ts | 168 +++++++++++++++ src/pages/account-income/model/display.ts | 173 ++++++++++++++++ .../account-income/model/income.schema.ts | 3 +- src/pages/account-income/model/period.ts | 34 +-- .../AccountIncomePage.test.tsx | 132 +----------- .../AccountIncomePage/AccountIncomePage.vm.ts | 196 +----------------- .../components/IncomeComparisons.view.tsx | 2 +- .../IncomePeriodForm/IncomePeriodForm.vm.ts | 3 +- .../OperationForm/OperationForm.stories.tsx | 1 - .../ui/OperationForm/OperationForm.test.tsx | 21 +- .../ui/OperationForm/OperationForm.view.tsx | 3 +- .../card/ui/OperationForm/OperationForm.vm.ts | 17 +- .../card/ui/OperationForm/RefundFields.vm.ts | 14 +- src/shared/lib/calendar.test.ts | 35 ++++ src/shared/lib/calendar.ts | 16 +- src/shared/lib/index.ts | 2 +- 20 files changed, 449 insertions(+), 392 deletions(-) create mode 100644 src/pages/account-income/model/display.test.ts create mode 100644 src/pages/account-income/model/display.ts create mode 100644 src/shared/lib/calendar.test.ts diff --git a/package.json b/package.json index 462018f..3985539 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "openapi:check": "node scripts/check-openapi.mjs", "knip": "npm run build:packages && knip", "deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long", - "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry", + "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && eslint . --max-warnings=0 && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry", "prepare": "husky", "preview": "vite preview", "deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long", diff --git a/src/entities/bank-card/model/form-validation.test.ts b/src/entities/bank-card/model/form-validation.test.ts index a99a3c5..f34effb 100644 --- a/src/entities/bank-card/model/form-validation.test.ts +++ b/src/entities/bank-card/model/form-validation.test.ts @@ -51,6 +51,9 @@ describe("bank form input", () => { expect(bankDateSchema.safeParse("2026-09-01").success).toBe(true); expect(bankDateSchema.safeParse("2026-09-02").success).toBe(false); expect(bankDateSchema.safeParse("2026-02-30").success).toBe(false); + expect(bankDateSchema.safeParse("0000-01-01").success).toBe(false); + expect(bankDateSchema.safeParse("0001-01-01").success).toBe(true); + expect(bankDateSchema.safeParse("0099-12-31").success).toBe(true); }); }); diff --git a/src/entities/bank-card/model/form-validation.ts b/src/entities/bank-card/model/form-validation.ts index 56f11db..0b87a25 100644 --- a/src/entities/bank-card/model/form-validation.ts +++ b/src/entities/bank-card/model/form-validation.ts @@ -1,3 +1,5 @@ +import { moscowDate } from "@/shared/lib"; + const DECIMAL_PART_COUNT = 2; const CENTS_PRECISION = 2; @@ -21,12 +23,7 @@ function bankMonth(value: null | string) { } function bankToday() { - return new Intl.DateTimeFormat("en-CA", { - day: "2-digit", - month: "2-digit", - timeZone: "Europe/Moscow", - year: "numeric", - }).format(new Date()); + return moscowDate(new Date()); } function moneyCents(value: string): bigint | null { diff --git a/src/entities/bank-card/model/form.schema.ts b/src/entities/bank-card/model/form.schema.ts index 3d4346b..9051b90 100644 --- a/src/entities/bank-card/model/form.schema.ts +++ b/src/entities/bank-card/model/form.schema.ts @@ -1,9 +1,9 @@ import { z } from "zod"; import { validationMessage } from "@/shared/i18n"; +import { calendarDate } from "@/shared/lib"; import { bankToday, moneyCents } from "./form-validation"; -const ISO_DATE_LENGTH = 10; const MAX_CARD_NAME_LENGTH = 100; const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n; const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n; @@ -34,11 +34,8 @@ const bankDateSchema = z .string() .refine( value => - /^\d{4}-\d{2}-\d{2}$/.test(value) + calendarDate.safeParse(value).success && value >= "0001-01-01" - && !Number.isNaN(Date.parse(`${value}T00:00:00Z`)) - && new Date(`${value}T00:00:00Z`).toISOString().slice(0, ISO_DATE_LENGTH) - === value && value <= bankToday(), { error: validationMessage("bank.validation.date") }, ); diff --git a/src/pages/account-income/model/display.test.ts b/src/pages/account-income/model/display.test.ts new file mode 100644 index 0000000..01ce9e7 --- /dev/null +++ b/src/pages/account-income/model/display.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; + +import type { IncomeAnalytics } from "@/entities/income"; + +import { resource } from "@/test/resource"; + +import { toIncomeDisplayModel } from "./display"; + +const analytics: IncomeAnalytics = { + accountId: "account-1", + bucketGranularity: "day", + buckets: [], + calculatedAt: "2026-08-27T00:00:00Z", + entries: [], + instruments: [], + period: { from: "2026-08-01", to: "2026-08-27" }, + previous: { + period: { from: "2026-07-01", to: "2026-07-31" }, + totalsByCurrency: [comparison("RUB")], + }, + totalsByCurrency: [], + yearComparison: null, +}; + +function comparison(currency: string) { + return { + compared: summary(currency, "1", "0"), + currency, + difference: summary(currency, "9", "2"), + selected: summary(currency, "10", "2"), + }; +} + +function money(currency: string, value: string) { + return { currency, value }; +} + +function summary(currency: string, coupon: string, dividend: string) { + return { + coupon: money(currency, coupon), + currency, + dividend: money(currency, dividend), + total: money(currency, String(Number(coupon) + Number(dividend))), + }; +} + +describe("income comparison display", () => { + it.each([ + ["25", "100", "+25 %"], + ["-25", "100", "-25 %"], + ["0", "100", "0 %"], + ["-0.000", "100", "0 %"], + ["0.0001", "100", "0 %"], + ["1", "3", "+33,3 %"], + ["0.01", "0.04", "+25 %"], + ["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"], + [ + "10", + "0", + null, + ], + [ + "0", + "0", + null, + ], + [ + "10", + "-0.00", + null, + ], + [ + "10", + "-100", + null, + ], + [ + "9".repeat(400), + "1", + null, + ], + [ + "1", + "9".repeat(400), + null, + ], + ])("formats change %s against base %s", (difference, base, expected) => { + const value = comparison("RUB"); + value.difference.coupon = money("RUB", difference); + value.compared.coupon = money("RUB", base); + + const display = toIncomeDisplayModel({ + ...analytics, + yearComparison: { + period: { from: "2025-08-01", to: "2025-08-27" }, + totalsByCurrency: [value], + }, + }); + + const result = display?.comparison?.values[0]; + expect( + result?.percentages.coupon?.replaceAll("\u{A0}", " ") + + ?? null, + ).toBe(expected); + expect(result?.difference.coupon.value).toBe(difference); + expect(result?.compared.coupon.value).toBe(base); + }); + + it("formats calendar ranges across years and preserves separate currencies", () => { + const display = toIncomeDisplayModel({ + ...analytics, + period: { from: "2025-12-01", to: "2026-01-09" }, + yearComparison: { + period: { from: "2024-12-01", to: "2025-01-09" }, + totalsByCurrency: [comparison("RUB"), comparison("USD")], + }, + }); + + expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe( + "01.12.2025 – 09.01.2026", + ); + expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe( + "01.12.2024 – 09.01.2025", + ); + expect(display?.comparison?.values.map(value => value.currency)).toEqual([ + "RUB", + "USD", + ]); + }); +}); + +it("maps comparison notices by role regardless of dependency order or resource key", () => { + const selected = { ...resource(analytics).meta, role: "selected" as const }; + + const previous + = { + ...resource(null).meta, + resourceKey: "opaque/year-looking-key", + role: "previous" as const, + sync: { + errorCode: "TBANK_UNAVAILABLE", + id: null, + state: "failed" as const, + }, + }; + + const year = { + ...resource(null).meta, + resourceKey: "opaque/previous-looking-key", + role: "year" as const, + unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", + }; + + for (const dependencies of [ + [selected, previous, year], + [year, selected, previous], + ]) { + const display = toIncomeDisplayModel(analytics, { + ...resource(analytics).meta, + dependencies, + }); + + expect(display?.previousNotice).toBe("comparisonFailed"); + expect(display?.yearNotice).toBe("yearUnavailable"); + expect(display?.previousComparison).not.toBeNull(); + } +}); diff --git a/src/pages/account-income/model/display.ts b/src/pages/account-income/model/display.ts new file mode 100644 index 0000000..e1eab94 --- /dev/null +++ b/src/pages/account-income/model/display.ts @@ -0,0 +1,173 @@ +import type { IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; + +import { formatDate } from "@/shared/lib"; + +import type { IncomePeriod } from "./period"; + +type ComparisonNotice + = | "comparisonFailed" + | "comparisonPending" + | "previousUnavailable" + | "yearUnavailable"; + +type IncomeDisplayModel = { + comparison: null + | { + comparedPeriod: string; + selectedPeriod: string; + values: (NonNullable< + IncomeAnalytics["yearComparison"] + >["totalsByCurrency"][number] & { + percentages: Record<"coupon" + | "dividend" + | "total", null | string>; + })[]; + }; + entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; + previousComparison: IncomeDisplayModel["comparison"]; + previousNotice: ComparisonNotice; + yearNotice: ComparisonNotice; +}; + +type IncomeMeta = ReturnType["meta"]; + +function comparisonDisplay( + period: IncomePeriod, + comparison: IncomeAnalytics["yearComparison"], +): IncomeDisplayModel["comparison"] { + if (!comparison) + return null; + + return { + comparedPeriod: formatComparisonPeriod(comparison.period), + selectedPeriod: formatComparisonPeriod(period), + values: comparison.totalsByCurrency.map(value => ({ + ...value, + percentages: { + coupon: formatIncomeChange( + value.difference.coupon.value, + value.compared.coupon.value, + ), + dividend: formatIncomeChange( + value.difference.dividend.value, + value.compared.dividend.value, + ), + total: formatIncomeChange( + value.difference.total.value, + value.compared.total.value, + ), + }, + })), + }; +} + +function comparisonNotice( + dependency: + NonNullable["dependencies"]>[number] | undefined, + unavailable: "previousUnavailable" | "yearUnavailable", +): ComparisonNotice { + if (!dependency || dependency.unavailableReason) return unavailable; + + return dependency.sync.state === "failed" + ? "comparisonFailed" + : "comparisonPending"; +} + +function formatComparisonPeriod(period: IncomePeriod): string { + return `${formatDate(period.from)} – ${formatDate(period.to)}`; +} + +function formatIncomeChange( + difference: string, + compared: string, +): null | string { + // Display-only ratio; server-provided Money values remain exact decimal strings. + const base = Number(compared); + if (!Number.isFinite(base) || base <= 0) + return null; + const ratio = Number(difference) / base; + if (!Number.isFinite(ratio)) + return null; + + return new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: 1, + signDisplay: "exceptZero", + style: "percent", + }).format(ratio); +} + +function toIncomeDisplayModel( + analytics: IncomeAnalytics, + meta?: IncomeMeta, +): IncomeDisplayModel | null { + const instruments = new Map< + string, + { + currency: string; + includedCount: number; + receiptCount: number; + } + >(); + + const currencies = new Set( + analytics.totalsByCurrency.map(({ currency }) => currency), + ); + + for (const instrument of analytics.instruments) { + if ( + typeof instrument.key !== "string" + || !instrument.key.trim() + || instruments.has(instrument.key) + || !currencies.has(instrument.total.currency) + ) + + return null; + instruments.set(instrument.key, { + currency: instrument.total.currency, + includedCount: 0, + receiptCount: instrument.receiptCount, + }); + } + + const entries: IncomeDisplayModel["entries"] = []; + const occurrences = new Map(); + + for (const entry of analytics.entries) { + if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim()) + return null; + const instrument = instruments.get(entry.instrumentKey); + if (!instrument || entry.payment?.currency !== instrument.currency) + return null; + const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]); + const occurrence = occurrences.get(occurrenceKey) ?? 0; + occurrences.set(occurrenceKey, occurrence + 1); + instrument.includedCount += 1; + entries.push({ + key: JSON.stringify([ + entry.instrumentKey, + entry.id, + occurrence, + entry.logoName, + ]), + operation: entry, + }); + } + + for (const instrument of instruments.values()) { + if (instrument.includedCount !== instrument.receiptCount) + return null; + } + + const previous = meta?.dependencies?.find(({ role }) => role === "previous"); + const year = meta?.dependencies?.find(({ role }) => role === "year"); + + return { + comparison: comparisonDisplay(analytics.period, analytics.yearComparison), + entries, + previousComparison: comparisonDisplay(analytics.period, analytics.previous), + previousNotice: comparisonNotice(previous, "previousUnavailable"), + yearNotice: comparisonNotice(year, "yearUnavailable"), + }; +} + +export { type IncomeDisplayModel, toIncomeDisplayModel }; diff --git a/src/pages/account-income/model/income.schema.ts b/src/pages/account-income/model/income.schema.ts index 9d22512..cc2c459 100644 --- a/src/pages/account-income/model/income.schema.ts +++ b/src/pages/account-income/model/income.schema.ts @@ -1,8 +1,9 @@ import { z } from "zod"; import { validationMessage } from "@/shared/i18n"; +import { moscowDate } from "@/shared/lib"; -import { moscowDate, parseDraftDate } from "./period"; +import { parseDraftDate } from "./period"; const draftDate = z .string() diff --git a/src/pages/account-income/model/period.ts b/src/pages/account-income/model/period.ts index 4662a7e..fb3a5c5 100644 --- a/src/pages/account-income/model/period.ts +++ b/src/pages/account-income/model/period.ts @@ -1,47 +1,22 @@ -const ISO_DATE_LENGTH = 10; +import { calendarDate } from "@/shared/lib"; + const DATE_PART_COUNT = 3; type IncomePeriod = { from: string; to: string }; -const datePattern = /^\d{4}-\d{2}-\d{2}$/; - function formatDraftDate(value: string): string { const [year, month, day] = value.split("-", DATE_PART_COUNT); return `${day}.${month}.${year}`; } -function isDate(value: string): boolean { - if (!datePattern.test(value)) return false; - const parsed = new Date(`${value}T00:00:00Z`); - - return ( - Number.isFinite(parsed.valueOf()) - && parsed.toISOString().slice(0, ISO_DATE_LENGTH) === value - ); -} - -function moscowDate(value: Date): string { - const parts = new Intl.DateTimeFormat("en-CA", { - day: "2-digit", - month: "2-digit", - timeZone: "Europe/Moscow", - year: "numeric", - }).formatToParts(value); - - const part = (type: string) => - parts.find(item => item.type === type)?.value; - - return `${part("year")}-${part("month")}-${part("day")}`; -} - function parseDraftDate(value: string): null | string { if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return null; const [day, month, year] = value.split(".", DATE_PART_COUNT); const date = `${year}-${month}-${day}`; - return isDate(date) + return calendarDate.safeParse(date).success ? date : null; } @@ -50,8 +25,5 @@ export { formatDraftDate, type IncomePeriod as IncomeForm, type IncomePeriod, - isDate, - ISO_DATE_LENGTH, - moscowDate, parseDraftDate, }; diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index 614b29d..0ea45e7 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -30,10 +30,9 @@ import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; -import { isDate } from "../../model/period"; +import { toIncomeDisplayModel } from "../../model/display"; import { AccountIncomePage } from "./AccountIncomePage"; import { - toIncomeDisplayModel, useAccountIncomePageViewModel, } from "./AccountIncomePage.vm"; import { IncomePayments } from "./components/IncomePayments.view"; @@ -469,12 +468,6 @@ describe("AccountIncomePage", () => { expect(within(panel).getByText("−2 ₽")).toBeInTheDocument(); }); - it("rejects malformed calendar dates without throwing", () => { - expect(isDate("2026-99-99")).toBe(false); - expect(isDate("2026-02-29")).toBe(false); - expect(isDate("2024-02-29")).toBe(true); - }); - it("renders realized text and comparisons without subscribing to events", async () => { const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 })); server.use( @@ -584,92 +577,6 @@ describe("AccountIncomePage", () => { }); }); -describe("income comparison display", () => { - it.each([ - ["25", "100", "+25 %"], - ["-25", "100", "-25 %"], - ["0", "100", "0 %"], - ["-0.000", "100", "0 %"], - ["0.0001", "100", "0 %"], - ["1", "3", "+33,3 %"], - ["0.01", "0.04", "+25 %"], - ["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"], - [ - "10", - "0", - null, - ], - [ - "0", - "0", - null, - ], - [ - "10", - "-0.00", - null, - ], - [ - "10", - "-100", - null, - ], - [ - "9".repeat(400), - "1", - null, - ], - [ - "1", - "9".repeat(400), - null, - ], - ])("formats change %s against base %s", (difference, base, expected) => { - const value = comparison("RUB"); - value.difference.coupon = money("RUB", difference); - value.compared.coupon = money("RUB", base); - - const display = toIncomeDisplayModel({ - ...analytics, - yearComparison: { - period: { from: "2025-08-01", to: "2025-08-27" }, - totalsByCurrency: [value], - }, - }); - - const result = display?.comparison?.values[0]; - expect( - result?.percentages.coupon?.replaceAll("\u{A0}", " ") - - ?? null, - ).toBe(expected); - expect(result?.difference.coupon.value).toBe(difference); - expect(result?.compared.coupon.value).toBe(base); - }); - - it("formats calendar ranges across years and preserves separate currencies", () => { - const display = toIncomeDisplayModel({ - ...analytics, - period: { from: "2025-12-01", to: "2026-01-09" }, - yearComparison: { - period: { from: "2024-12-01", to: "2025-01-09" }, - totalsByCurrency: [comparison("RUB"), comparison("USD")], - }, - }); - - expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe( - "01.12.2025 – 09.01.2026", - ); - expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe( - "01.12.2024 – 09.01.2025", - ); - expect(display?.comparison?.values.map(value => value.currency)).toEqual([ - "RUB", - "USD", - ]); - }); -}); - const LocationProbe: FC = () => { const location = useLocation(); const navigate = useNavigate(); @@ -978,43 +885,6 @@ describe("income period and snapshot boundary", () => { }); }); -it("maps comparison notices by role regardless of dependency order or resource key", () => { - const selected = { ...resource(analytics).meta, role: "selected" as const }; - - const previous - = { - ...resource(null).meta, - resourceKey: "opaque/year-looking-key", - role: "previous" as const, - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: null, - state: "failed" as const, - }, - }; - - const year = { - ...resource(null).meta, - resourceKey: "opaque/previous-looking-key", - role: "year" as const, - unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", - }; - - for (const dependencies of [ - [selected, previous, year], - [year, selected, previous], - ]) { - const display = toIncomeDisplayModel(analytics, { - ...resource(analytics).meta, - dependencies, - }); - - expect(display?.previousNotice).toBe("comparisonFailed"); - expect(display?.yearNotice).toBe("yearUnavailable"); - expect(display?.previousComparison).not.toBeNull(); - } -}); - it.each([false, true])( "shows unavailable comparisons for absent roles without positional fallback (legacy=%s)", async (legacy) => { diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index ab419df..937f9e7 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -5,16 +5,17 @@ import { useParams, useSearchParams } from "react-router"; import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { useScreenSync } from "@/features/tbank-connection"; -import { formatDate } from "@/shared/lib"; +import { isValidPeriod, moscowDate } from "@/shared/lib"; +import { + type IncomeDisplayModel, + toIncomeDisplayModel, +} from "../../model/display"; import { incomeFormSchema } from "../../model/income.schema"; import { formatDraftDate, type IncomeForm, type IncomePeriod, - isDate, - ISO_DATE_LENGTH, - moscowDate, parseDraftDate, } from "../../model/period"; @@ -43,33 +44,6 @@ type AccountIncomePageViewModel } | { state: "loading" }; -type ComparisonNotice - = | "comparisonFailed" - | "comparisonPending" - | "previousUnavailable" - | "yearUnavailable"; - -type IncomeDisplayModel = { - comparison: null - | { - comparedPeriod: string; - selectedPeriod: string; - values: (NonNullable< - IncomeAnalytics["yearComparison"] - >["totalsByCurrency"][number] & { - percentages: Record<"coupon" - | "dividend" - | "total", null | string>; - })[]; - }; - entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; - previousComparison: IncomeDisplayModel["comparison"]; - previousNotice: ComparisonNotice; - yearNotice: ComparisonNotice; -}; - -type IncomeMeta = ReturnType["meta"]; - const useAccountIncomePageViewModel = ( scopeId?: string, ): AccountIncomePageViewModel => { @@ -86,7 +60,7 @@ const useAccountIncomePageViewModel = ( ); const period = useMemo( - () => (isValidPeriod(requested) ? requested : defaultPeriod()), + () => (isValidIncomePeriod(requested) ? requested : defaultPeriod()), [requested], ); @@ -104,7 +78,7 @@ const useAccountIncomePageViewModel = ( ); useEffect(() => { - if (!isValidPeriod(requested)) + if (!isValidIncomePeriod(requested)) setSearchParameters(period, { replace: true }); }, [period, requested, setSearchParameters]); useEffect(() => { @@ -156,48 +130,6 @@ const useAccountIncomePageViewModel = ( }; }; -function comparisonDisplay( - period: IncomePeriod, - comparison: IncomeAnalytics["yearComparison"], -): IncomeDisplayModel["comparison"] { - if (!comparison) - return null; - - return { - comparedPeriod: formatComparisonPeriod(comparison.period), - selectedPeriod: formatComparisonPeriod(period), - values: comparison.totalsByCurrency.map(value => ({ - ...value, - percentages: { - coupon: formatIncomeChange( - value.difference.coupon.value, - value.compared.coupon.value, - ), - dividend: formatIncomeChange( - value.difference.dividend.value, - value.compared.dividend.value, - ), - total: formatIncomeChange( - value.difference.total.value, - value.compared.total.value, - ), - }, - })), - }; -} - -function comparisonNotice( - dependency: - NonNullable["dependencies"]>[number] | undefined, - unavailable: "previousUnavailable" | "yearUnavailable", -): ComparisonNotice { - if (!dependency || dependency.unavailableReason) return unavailable; - - return dependency.sync.state === "failed" - ? "comparisonFailed" - : "comparisonPending"; -} - function defaultPeriod(): IncomePeriod { const today = moscowDate(new Date()); @@ -208,120 +140,12 @@ function draftPeriod(period: IncomePeriod): IncomeForm { return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) }; } -function formatComparisonPeriod(period: IncomePeriod): string { - return `${formatDate(period.from)} – ${formatDate(period.to)}`; -} - -function formatIncomeChange( - difference: string, - compared: string, -): null | string { - // Display-only ratio; server-provided Money values remain exact decimal strings. - const base = Number(compared); - if (!Number.isFinite(base) || base <= 0) - return null; - const ratio = Number(difference) / base; - if (!Number.isFinite(ratio)) - return null; - - return new Intl.NumberFormat("ru-RU", { - maximumFractionDigits: 1, - signDisplay: "exceptZero", - style: "percent", - }).format(ratio); -} - -function isValidPeriod(period: IncomePeriod): period is IncomePeriod { - if (!isDate(period.from) || !isDate(period.to)) return false; - const from = new Date(`${period.from}T00:00:00Z`); - const to = new Date(`${period.to}T00:00:00Z`); - const today = new Date(`${moscowDate(new Date())}T00:00:00Z`); - - return ( - from.toISOString().slice(0, ISO_DATE_LENGTH) === period.from - && to.toISOString().slice(0, ISO_DATE_LENGTH) === period.to - && from <= to - && to <= today - ); -} - -function toIncomeDisplayModel( - analytics: IncomeAnalytics, - meta?: IncomeMeta, -): IncomeDisplayModel | null { - const instruments = new Map< - string, - { - currency: string; - includedCount: number; - receiptCount: number; - } - >(); - - const currencies = new Set( - analytics.totalsByCurrency.map(({ currency }) => currency), - ); - - for (const instrument of analytics.instruments) { - if ( - typeof instrument.key !== "string" - || !instrument.key.trim() - || instruments.has(instrument.key) - || !currencies.has(instrument.total.currency) - ) - - return null; - instruments.set(instrument.key, { - currency: instrument.total.currency, - includedCount: 0, - receiptCount: instrument.receiptCount, - }); - } - - const entries: IncomeDisplayModel["entries"] = []; - const occurrences = new Map(); - - for (const entry of analytics.entries) { - if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim()) - return null; - const instrument = instruments.get(entry.instrumentKey); - if (!instrument || entry.payment?.currency !== instrument.currency) - return null; - const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]); - const occurrence = occurrences.get(occurrenceKey) ?? 0; - occurrences.set(occurrenceKey, occurrence + 1); - instrument.includedCount += 1; - entries.push({ - key: JSON.stringify([ - entry.instrumentKey, - entry.id, - occurrence, - entry.logoName, - ]), - operation: entry, - }); - } - - for (const instrument of instruments.values()) { - if (instrument.includedCount !== instrument.receiptCount) - return null; - } - - const previous = meta?.dependencies?.find(({ role }) => role === "previous"); - const year = meta?.dependencies?.find(({ role }) => role === "year"); - - return { - comparison: comparisonDisplay(analytics.period, analytics.yearComparison), - entries, - previousComparison: comparisonDisplay(analytics.period, analytics.previous), - previousNotice: comparisonNotice(previous, "previousUnavailable"), - yearNotice: comparisonNotice(year, "yearUnavailable"), - }; +function isValidIncomePeriod(period: IncomePeriod): boolean { + return isValidPeriod(period.from, period.to) + && period.to <= moscowDate(new Date()); } export { type AccountIncomePageViewModel, - type IncomeDisplayModel, - toIncomeDisplayModel, useAccountIncomePageViewModel, }; diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx index 2cd9f14..5b0ceeb 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { formatMoney } from "@/shared/lib"; -import type { IncomeDisplayModel } from "../AccountIncomePage.vm"; +import type { IncomeDisplayModel } from "../../../model/display"; import classes from "../AccountIncomePage.module.css"; diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.vm.ts b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.vm.ts index 3bf5ae2..199c5c1 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.vm.ts @@ -2,10 +2,11 @@ import { useState } from "react"; import { useController, useFormContext, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { moscowDate } from "@/shared/lib"; + import { formatDraftDate, type IncomeForm, - moscowDate, parseDraftDate, } from "../../../../model/period"; diff --git a/src/pages/card/ui/OperationForm/OperationForm.stories.tsx b/src/pages/card/ui/OperationForm/OperationForm.stories.tsx index 78d57d9..54f2c93 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.stories.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.stories.tsx @@ -91,7 +91,6 @@ const OperationExample: FC = (props) => { }, ], limit: { currency: "RUB", value: "500" }, - onSelect: () => {}, }} saveDisabled={false} sourcePage={1} diff --git a/src/pages/card/ui/OperationForm/OperationForm.test.tsx b/src/pages/card/ui/OperationForm/OperationForm.test.tsx index 1729b71..8356b6d 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.test.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.test.tsx @@ -245,14 +245,19 @@ describe("OperationForm", () => { version: 1, }; + const recent = { + ...purchase, + availableRefund: money("100"), + description: "Недавняя покупка", + id: "recent", + }; + server.use( http.get("*/api/v1/bank-cards/a/expenses", ({ request }) => { return HttpResponse.json({ hasNext: new URL(request.url).searchParams.get("page") !== "2", items: - new URL(request.url).searchParams.get("page") === "2" - ? [purchase] - : [], + [new URL(request.url).searchParams.get("page") === "2" ? purchase : recent], page: 1, total: 1, }); @@ -260,9 +265,19 @@ describe("OperationForm", () => { http.get("*/api/v1/bank-cards/operations/purchase", () => HttpResponse.json(purchase), ), + http.get("*/api/v1/bank-cards/operations/recent", () => + HttpResponse.json(recent), + ), ); const close = setup(); await select(/Вид операции/, "Возврат"); + await select(/Исходная покупка/, /Недавняя покупка/); + await screen.findByText("Доступно к возврату: 100 ₽"); + fireEvent.change(screen.getByLabelText(/Сумма/), { + target: { value: "150" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await screen.findByText("Сумма превышает доступный возврат."); await waitFor(() => expect(screen.getByRole("button", { name: "Далее" })).toBeEnabled(), ); diff --git a/src/pages/card/ui/OperationForm/OperationForm.view.tsx b/src/pages/card/ui/OperationForm/OperationForm.view.tsx index 159b423..02db9f9 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.view.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.view.tsx @@ -142,12 +142,11 @@ type RefundFieldsProps = { editing: boolean; expenses: Choice[]; limit: BankOperation["availableRefund"] | undefined; - onSelect: (id: string) => void; }; const RefundFields: FC = (props) => { const { errors, isSubmitting, onExpenseChange, originalExpenseIdField, t } - = useRefundFieldsVm(props); + = useRefundFieldsVm(); return ( diff --git a/src/pages/card/ui/OperationForm/OperationForm.vm.ts b/src/pages/card/ui/OperationForm/OperationForm.vm.ts index a8eb1da..c45f178 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.vm.ts +++ b/src/pages/card/ui/OperationForm/OperationForm.vm.ts @@ -35,11 +35,6 @@ const useOperationFormVm = (props: OperationFormProps) => { const [latest, setLatest] = useState(); const [sourcePage, setSourcePage] = useState(1); - const [sourceId, setSourceId] = useState( - props.operation?.originalExpenseId ?? "", - ); - - const sourceQuery = useBankOperation(sourceId || undefined); const currentQuery = useBankOperation(props.operation?.id); const [refundLimit, setRefundLimit] = useState( @@ -64,7 +59,7 @@ const useOperationFormVm = (props: OperationFormProps) => { originalExpenseId: props.operation?.originalExpenseId ?? "", targetCardId: props.operation?.targetCardId ?? "", }, - resolver: zodResolver( + resolver: (values, context, options) => zodResolver( createOperationSchema({ cards: props.cards, categories: categories.data, @@ -74,9 +69,16 @@ const useOperationFormVm = (props: OperationFormProps) => { : sourceQuery.data?.availableRefund, submittedValues, }), - ), + )(values, context, options), }); + const sourceId = useWatch({ + control: methods.control, + name: "originalExpenseId", + }); + + const sourceQuery = useBankOperation(sourceId || undefined); + const kind = useWatch({ control: methods.control, name: "kind" }); const expenses = useBankExpenses( @@ -242,7 +244,6 @@ const useOperationFormVm = (props: OperationFormProps) => { limit: props.operation ? refundLimit : sourceQuery.data?.availableRefund, - onSelect: setSourceId, }, saveDisabled: (kind === "expense" && !categories.data) diff --git a/src/pages/card/ui/OperationForm/RefundFields.vm.ts b/src/pages/card/ui/OperationForm/RefundFields.vm.ts index c53fab9..afaf3ec 100644 --- a/src/pages/card/ui/OperationForm/RefundFields.vm.ts +++ b/src/pages/card/ui/OperationForm/RefundFields.vm.ts @@ -1,20 +1,9 @@ import { useController, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import type { BankOperation } from "@/entities/bank-card"; - import type { OperationValues } from "./OperationForm.types"; -type Choice = { label: string; value: string }; - -type RefundFieldsProps = { - editing: boolean; - expenses: Choice[]; - limit: BankOperation["availableRefund"] | undefined; - onSelect: (id: string) => void; -}; - -function useRefundFieldsVm(props: RefundFieldsProps) { +function useRefundFieldsVm() { const { t } = useTranslation(); const { @@ -29,7 +18,6 @@ function useRefundFieldsVm(props: RefundFieldsProps) { const onExpenseChange: (value: null | string) => void = (value) => { originalExpenseIdField.onChange(value ?? ""); - props.onSelect(value ?? ""); }; return { errors, isSubmitting, onExpenseChange, originalExpenseIdField, t }; diff --git a/src/shared/lib/calendar.test.ts b/src/shared/lib/calendar.test.ts new file mode 100644 index 0000000..ae8a521 --- /dev/null +++ b/src/shared/lib/calendar.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { isValidPeriod, moscowDate } from "./calendar"; +import { calendarDate } from "./calendar.schema"; + +describe("calendar dates", () => { + it.each([ + ["0000-02-29", true], + ["0001-01-01", true], + ["0099-12-31", true], + ["1900-02-29", false], + ["2000-02-29", true], + ["2024-02-29", true], + ["2026-02-29", false], + ["2026-99-99", false], + ["2026-1-01", false], + ["", false], + ])("validates %s without Date normalization", (value, valid) => { + expect(calendarDate.safeParse(value).success).toBe(valid); + }); + + it("accepts open or single-day periods and rejects reversed ranges", () => { + expect(isValidPeriod(null, null)).toBe(true); + expect(isValidPeriod(null, "2026-09-01")).toBe(true); + expect(isValidPeriod("2026-09-01", null)).toBe(true); + expect(isValidPeriod("2026-09-01", "2026-09-01")).toBe(true); + expect(isValidPeriod("2026-09-02", "2026-09-01")).toBe(false); + expect(isValidPeriod("", "2026-09-01")).toBe(false); + }); + + it("changes the calendar day at Moscow midnight across a year boundary", () => { + 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"); + }); +}); diff --git a/src/shared/lib/calendar.ts b/src/shared/lib/calendar.ts index 997b5c8..81ba27f 100644 --- a/src/shared/lib/calendar.ts +++ b/src/shared/lib/calendar.ts @@ -9,4 +9,18 @@ function isValidPeriod(from: null | string, to: null | string) { ); } -export { isValidPeriod }; +function moscowDate(value: Date): string { + const parts = new Intl.DateTimeFormat("en-CA", { + day: "2-digit", + month: "2-digit", + timeZone: "Europe/Moscow", + year: "numeric", + }).formatToParts(value); + + const part = (type: string) => + parts.find(item => item.type === type)?.value; + + return `${part("year")}-${part("month")}-${part("day")}`; +} + +export { isValidPeriod, moscowDate }; diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index d490133..8fd812a 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,4 +1,4 @@ -export { isValidPeriod } from "./calendar"; +export { isValidPeriod, moscowDate } from "./calendar"; export { calendarDate } from "./calendar.schema"; export { formatChartValue, -- 2.54.0 From fb3a5a6eaa0223a15ea2c9f585a06459371f0a25 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 20:34:33 +0300 Subject: [PATCH 02/16] refactor: remove redundant page view models --- .../AccountEventsPage.filters.test.tsx | 123 ++++++------------ .../AccountEventsPage/AccountEventsPage.vm.ts | 26 ++-- .../AccountEventsPageLoaded.view.tsx | 8 +- .../components/AccountEventsPageLoaded.vm.ts | 24 ---- .../AccountOperationsPage.vm.ts | 44 +++---- .../AccountOperationsPageLoaded.view.tsx | 44 +++---- .../AccountOperationsPageLoaded.vm.ts | 78 ----------- .../cards/ui/CardsPage/CardsPage.view.tsx | 28 ++-- src/pages/cards/ui/CardsPage/CardsPage.vm.ts | 12 +- .../cards/ui/CardsPage/CardsPageView.vm.ts | 35 ----- 10 files changed, 109 insertions(+), 313 deletions(-) delete mode 100644 src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts delete mode 100644 src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.vm.ts delete mode 100644 src/pages/cards/ui/CardsPage/CardsPageView.vm.ts diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx index 4968f31..fbc7a51 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx @@ -18,7 +18,7 @@ import { useLocation, useNavigate, } from "react-router"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; @@ -47,13 +47,40 @@ const Navigation: FC = () => { ); }; +function renderEvents(accountId: string, searches: string[]) { + return render( + + + + `/accounts/${accountId}/events${search}`, + )} + initialIndex={0} + > + + + + } + path="/accounts/:accountId/events" + /> + + + + , + ); +} + describe("Upcoming event date filters", () => { - it("keeps reads and sync disabled without an account scope", async () => { + beforeEach(() => { configureAuthClient({ accessToken: () => "token", clear: vi.fn(), refresh: () => Promise.resolve(true), }); + }); + + it("keeps reads and sync disabled without an account scope", async () => { const requests: string[] = []; server.use( http.all("*/api/v1/*", ({ request }) => { @@ -87,11 +114,6 @@ describe("Upcoming event date filters", () => { ])( "keeps event calendar dates in GET and source sync requests: %s", async (search) => { - configureAuthClient({ - accessToken: () => "token", - clear: vi.fn(), - refresh: () => Promise.resolve(true), - }); const requested: string[] = []; const syncRequests: unknown[] = []; server.use( @@ -153,11 +175,6 @@ describe("Upcoming event date filters", () => { it.each(["account-1", "all"])( "normalizes invalid links and history while preserving one-sided future dates for %s", async (accountId) => { - configureAuthClient({ - accessToken: () => "token", - clear: vi.fn(), - refresh: () => Promise.resolve(true), - }); const searches: string[] = []; server.use( http.get( @@ -169,31 +186,13 @@ describe("Upcoming event date filters", () => { }, ), ); - render( - - - `/accounts/${accountId}/events${search}`)} - initialIndex={0} - > - - - - } - path="/accounts/:accountId/events" - /> - - - - , - ); + renderEvents(accountId, [ + "?from=bad&to=2099-02-28", + "?from=2099-02-30", + "?from=2099-03-01&to=2099-02-28", + "?from=2099-02-01", + "?to=2099-02-28", + ]); await screen.findByLabelText("Период"); for (const expected of ["", "", "?from=2099-02-01", "?to=2099-02-28"]) { @@ -221,11 +220,6 @@ describe("Upcoming event date filters", () => { it.each(["account-1", "all"])( "lets users reset filters after a request failure for %s", async (accountId) => { - configureAuthClient({ - accessToken: () => "token", - clear: vi.fn(), - refresh: () => Promise.resolve(true), - }); server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/events", @@ -235,24 +229,7 @@ describe("Upcoming event date filters", () => { : HttpResponse.json(resource({ events: [] })), ), ); - render( - - - - - - - } - path="/accounts/:accountId/events" - /> - - - - , - ); + renderEvents(accountId, ["?from=2099-02-01"]); await screen.findByRole("button", { name: "Повторить" }); fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); await screen.findByLabelText("Период"); @@ -263,11 +240,6 @@ describe("Upcoming event date filters", () => { it.each(["account-1", "all"])( "applies dates only on submit, restores URL dates, and resets from empty results for %s", async (accountId) => { - configureAuthClient({ - accessToken: () => "token", - clear: vi.fn(), - refresh: () => Promise.resolve(true), - }); const searches: string[] = []; server.use( http.get( @@ -286,26 +258,7 @@ describe("Upcoming event date filters", () => { }, ), ); - render( - - - - - - - } - path="/accounts/:accountId/events" - /> - - - - , - ); + renderEvents(accountId, ["?from=2099-02-01&to=2099-02-28"]); const from = await screen.findByLabelText("Период"); expect(from).toHaveTextContent("01.02.2099 – 28.02.2099"); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index 20d0a67..e839d83 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -11,13 +11,11 @@ import { isValidPeriod } from "@/shared/lib"; type AccountEventsPageViewModel = | { canApply: boolean; - draftFrom: null | string; - draftTo: null | string; + draftPeriod: [null | string, null | string]; events: BrokerageEvent[]; lastSuccessfulAt?: null | string; onApply: () => void; - onDraftFromChange: (value: null | string) => void; - onDraftToChange: (value: null | string) => void; + onPeriodChange: (value: [null | string, null | string]) => void; onReset: () => void; partial?: boolean; refreshError: boolean; @@ -47,8 +45,12 @@ function useAccountEventsPageViewModel( ? urlTo : null; - const [draftFrom, setDraftFrom] = useState(appliedFrom); - const [draftTo, setDraftTo] = useState(appliedTo); + const [draftPeriod, setDraftPeriod] = useState<[ + null | string, + null | string, + ]>([appliedFrom, appliedTo]); + + const [draftFrom, draftTo] = draftPeriod; useEffect(() => { if (isValidUrlPeriod) return; const normalized = new URLSearchParams(searchParameters); @@ -64,8 +66,7 @@ function useAccountEventsPageViewModel( if (previousPeriod[0] !== appliedFrom || previousPeriod[1] !== appliedTo) { setPreviousPeriod([appliedFrom, appliedTo]); - setDraftFrom(appliedFrom); - setDraftTo(appliedTo); + setDraftPeriod([appliedFrom, appliedTo]); } const events = useBrokerageEvents(accountId, { @@ -86,8 +87,7 @@ function useAccountEventsPageViewModel( if (events.isLoading) return { state: "loading" }; const onReset = () => { - setDraftFrom(null); - setDraftTo(null); + setDraftPeriod([null, null]); setSearchParameters({}); }; @@ -105,8 +105,7 @@ function useAccountEventsPageViewModel( canApply: (draftFrom !== appliedFrom || draftTo !== appliedTo) && isValidPeriod(draftFrom, draftTo), - draftFrom, - draftTo, + draftPeriod, events: events.data.events, lastSuccessfulAt: events.meta?.lastSuccessfulAt, onApply: () => { @@ -116,8 +115,7 @@ function useAccountEventsPageViewModel( ...(draftTo && { to: draftTo }), }); }, - onDraftFromChange: setDraftFrom, - onDraftToChange: setDraftTo, + onPeriodChange: setDraftPeriod, onReset, partial: events.meta?.availability !== "complete", refreshError: events.isError || sync.sourceError || sync.transportError, diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx index 8956252..362a84f 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -10,6 +10,7 @@ import { Title, } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; +import { useTranslation } from "react-i18next"; import { BrokerageEventCard } from "@/entities/brokerage-event"; import { formatSnapshotTime } from "@/shared/lib"; @@ -17,7 +18,6 @@ import { formatSnapshotTime } from "@/shared/lib"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view"; -import { useAccountEventsPageLoadedVm } from "./AccountEventsPageLoaded.vm"; type AccountEventsPageLoadedProps = Extract< AccountEventsPageViewModel, @@ -56,7 +56,7 @@ const AccountEventsPageLoadedEvents: FC = ( }; const AccountEventsPageLoaded: FC = (props) => { - const { onPeriodChange, t } = useAccountEventsPageLoadedVm(props); + const { t } = useTranslation(); return ( @@ -100,13 +100,13 @@ const AccountEventsPageLoaded: FC = (props) => { clearable clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }} label={t("ledger.period")} - onChange={onPeriodChange} + onChange={props.onPeriodChange} popoverProps={{ middlewares: { shift: { crossAxis: true } }, returnFocus: true, }} type="range" - value={[props.draftFrom, props.draftTo]} + value={props.draftPeriod} valueFormat="DD.MM.YYYY" /> diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts deleted file mode 100644 index af03a32..0000000 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useTranslation } from "react-i18next"; - -import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; - -type AccountEventsPageLoadedProps = Extract< - AccountEventsPageViewModel, - { state: "loaded" } ->; - -function useAccountEventsPageLoadedVm(props: AccountEventsPageLoadedProps) { - const { t } = useTranslation(); - - const onPeriodChange: (value: [null | string, null | string]) => void = ([ - from, - to, - ]) => { - props.onDraftFromChange(from); - props.onDraftToChange(to); - }; - - return { onPeriodChange, t }; -} - -export { useAccountEventsPageLoadedVm }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts index ed1676b..e3b3c6a 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts @@ -13,22 +13,22 @@ import { useScreenSync } from "@/features/tbank-connection"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import { isValidPeriod } from "@/shared/lib"; +const operationCategorySet = new Set(operationCategories); + type AccountOperationsPageViewModel = | { canApply: boolean; draftCategories: OperationCategory[]; - draftFrom: null | string; - draftTo: null | string; + draftPeriod: [null | string, null | string]; hasNext: boolean; hasPrevious: boolean; historyLoading?: boolean; items: BrokerageOperation[]; lastSuccessfulAt?: null | string; onApply: () => void; - onDraftCategoriesChange: (value: OperationCategory[]) => void; - onDraftFromChange: (value: null | string) => void; - onDraftToChange: (value: null | string) => void; + onCategoriesChange: (value: string[]) => void; onNext: () => void; + onPeriodChange: (value: [null | string, null | string]) => void; onPrevious: () => void; onReset: () => void; partial?: boolean; @@ -64,8 +64,12 @@ const useAccountOperationsPageVm = ( (searchParameters.get("categories") ?? "").split(",").includes(category), ); - const [draftFrom, setDraftFrom] = useState(appliedFrom); - const [draftTo, setDraftTo] = useState(appliedTo); + const [draftPeriod, setDraftPeriod] = useState<[ + null | string, + null | string, + ]>([appliedFrom, appliedTo]); + + const [draftFrom, draftTo] = draftPeriod; const [draftCategories, setDraftCategories] = useState(appliedCategories); @@ -100,15 +104,8 @@ const useAccountOperationsPageVm = ( if (previousSearch !== searchParameters) { setPreviousSearch(searchParameters); - setDraftFrom(appliedFrom); - setDraftTo(appliedTo); - setDraftCategories( - operationCategories.filter(category => - (searchParameters.get("categories") ?? "") - .split(",") - .includes(category), - ), - ); + setDraftPeriod([appliedFrom, appliedTo]); + setDraftCategories(appliedCategories); } const firstPageQuery = { @@ -154,8 +151,7 @@ const useAccountOperationsPageVm = ( }; const onReset = () => { - setDraftFrom(null); - setDraftTo(null); + setDraftPeriod([null, null]); setDraftCategories([]); setSearchParameters({}); resetCursors(); @@ -182,8 +178,7 @@ const useAccountOperationsPageVm = ( return { canApply: isDirty && isValidPeriod(draftFrom, draftTo), draftCategories, - draftFrom, - draftTo, + draftPeriod, hasNext: Boolean(nextCursor && operations.data.hasNext), hasPrevious: cursors.length > 0, historyLoading: @@ -202,13 +197,16 @@ const useAccountOperationsPageVm = ( }); resetCursors(); }, - onDraftCategoriesChange: setDraftCategories, - onDraftFromChange: setDraftFrom, - onDraftToChange: setDraftTo, + onCategoriesChange: values => setDraftCategories( + values.filter((value): value is OperationCategory => + operationCategorySet.has(value), + ), + ), onNext: () => { if (nextCursor) setPagination({ cursors: [...cursors, nextCursor], scopeKey }); }, + onPeriodChange: setDraftPeriod, onPrevious: () => setPagination({ cursors: cursors.slice(0, -1), scopeKey }), onReset, diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx index c981e79..af02e2d 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx @@ -14,13 +14,11 @@ import { DatePickerInput } from "@mantine/dates"; import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; -import { LedgerEntryRow } from "@/entities/broker-operation"; +import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation"; import { formatSnapshotTime } from "@/shared/lib"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; -import { useAccountOperationsPageLoadedVm } from "./AccountOperationsPageLoaded.vm"; - type AccountOperationsPageLoadedProps = Extract< AccountOperationsPageViewModel, { state: "loaded" } @@ -56,22 +54,12 @@ const AccountOperationsPageLoadedOperations: FC< const AccountOperationsPageLoaded: FC = ( props, ) => { - const { - canApply, - categoryData, - draftCategories, - draftFrom, - draftTo, - hasNext, - hasPrevious, - onApply, - onCategoriesChange, - onNext, - onPeriodChange, - onPrevious, - onReset, - t, - } = useAccountOperationsPageLoadedVm(props); + const { t } = useTranslation(); + + const categoryData = operationCategories.map(category => ({ + label: t(`ledger.category.${category}`), + value: category, + })); return ( @@ -115,13 +103,13 @@ const AccountOperationsPageLoaded: FC = ( clearable clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }} label={t("ledger.period")} - onChange={onPeriodChange} + onChange={props.onPeriodChange} popoverProps={{ middlewares: { shift: { crossAxis: true } }, returnFocus: true, }} type="range" - value={[draftFrom, draftTo]} + value={props.draftPeriod} valueFormat="DD.MM.YYYY" /> @@ -131,30 +119,30 @@ const AccountOperationsPageLoaded: FC = ( clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }} data={categoryData} label={t("ledger.categories")} - onChange={onCategoriesChange} + onChange={props.onCategoriesChange} placeholder={t("ledger.categoriesPlaceholder")} - value={draftCategories} + value={props.draftCategories} /> - - - {!!(hasNext || hasPrevious) && ( + {!!(props.hasNext || props.hasPrevious) && ( - - diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.vm.ts deleted file mode 100644 index 5ee01cc..0000000 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.vm.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { useTranslation } from "react-i18next"; - -import { - operationCategories, - type OperationCategory, -} from "@/entities/broker-operation"; - -import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; - -type AccountOperationsPageLoadedProps = Extract< - AccountOperationsPageViewModel, - { state: "loaded" } ->; - -const operationCategorySet = new Set(operationCategories); - -function fromMultiSelectValue(value: string[]) { - return value.filter((item): item is OperationCategory => - operationCategorySet.has(item), - ); -} - -function useAccountOperationsPageLoadedVm( - props: AccountOperationsPageLoadedProps, -) { - const { - canApply, - draftCategories, - draftFrom, - draftTo, - hasNext, - hasPrevious, - onApply, - onDraftCategoriesChange, - onDraftFromChange, - onDraftToChange, - onNext, - onPrevious, - onReset, - } = props; - - const { t } = useTranslation(); - - const categoryData = operationCategories.map(category => ({ - label: t(`ledger.category.${category}`), - value: category, - })); - - const onPeriodChange: (value: [null | string, null | string]) => void = ([ - from, - to, - ]) => { - onDraftFromChange(from); - onDraftToChange(to); - }; - - const onCategoriesChange: (value: string[]) => void = value => - onDraftCategoriesChange(fromMultiSelectValue(value)); - - return { - canApply, - categoryData, - draftCategories, - draftFrom, - draftTo, - hasNext, - hasPrevious, - onApply, - onCategoriesChange, - onNext, - onPeriodChange, - onPrevious, - onReset, - t, - }; -} - -export { useAccountOperationsPageLoadedVm }; diff --git a/src/pages/cards/ui/CardsPage/CardsPage.view.tsx b/src/pages/cards/ui/CardsPage/CardsPage.view.tsx index 1b1ea0b..a59d1f4 100644 --- a/src/pages/cards/ui/CardsPage/CardsPage.view.tsx +++ b/src/pages/cards/ui/CardsPage/CardsPage.view.tsx @@ -27,14 +27,12 @@ import { import type { CardsPageVm } from "./CardsPage.vm"; import classes from "./CardsPage.module.css"; -import { useCardsPageViewVm } from "./CardsPageView.vm"; type CardsPageViewProps = { vm: CardsPageVm }; const CardsPageViewCards: FC = (props) => { const { t } = useTranslation(); const { vm } = props; - const { onAddCard } = useCardsPageViewVm(props); if (vm.items) { if (vm.items.length > 0) { @@ -59,7 +57,7 @@ const CardsPageViewCards: FC = (props) => { return ( {t("bank.addCard")}} + action={} text={text} title={t("bank.empty")} /> @@ -80,16 +78,8 @@ const CardsPageViewCards: FC = (props) => { }; const CardsPageView: FC = (props) => { - const { - onAddCard, - onArchiveChange, - onCategories, - retryCards, - retryCategories, - retrySummary, - t, - vm, - } = useCardsPageViewVm(props); + const { vm } = props; + const { t } = useTranslation(); return ( @@ -98,7 +88,7 @@ const CardsPageView: FC = (props) => { = (props) => { @@ -119,7 +109,7 @@ const CardsPageView: FC = (props) => { {t(vm.items ? "bank.refreshError" : "bank.error")} - @@ -134,7 +124,7 @@ const CardsPageView: FC = (props) => { + + {!!vm.partial && {t("investment.partial")}} + + + + {!vm.canSearch && {t("stock.searchHint")}} + + {!!vm.error && ( + + + + {t( + isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) + || isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken) + ? `${kind}.connect` + : "stock.sectionError", + )} + + + + {t("accounts.openSettings")} + + + + + + )} + + + + ); +}; + +export { InstrumentSearch }; diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.module.css b/src/features/instrument-search/ui/InstrumentSearchCard/InstrumentSearchCard.module.css similarity index 100% rename from src/pages/bonds/ui/BondsPage/BondsPage.module.css rename to src/features/instrument-search/ui/InstrumentSearchCard/InstrumentSearchCard.module.css diff --git a/src/features/instrument-search/ui/InstrumentSearchCard/InstrumentSearchCard.view.tsx b/src/features/instrument-search/ui/InstrumentSearchCard/InstrumentSearchCard.view.tsx new file mode 100644 index 0000000..70623f4 --- /dev/null +++ b/src/features/instrument-search/ui/InstrumentSearchCard/InstrumentSearchCard.view.tsx @@ -0,0 +1,38 @@ +import type { FC, PropsWithChildren, ReactNode } from "react"; + +import { Avatar, Paper, Text } from "@mantine/core"; +import { Link } from "react-router"; + +import classes from "./InstrumentSearchCard.module.css"; + +type InstrumentSearchCardProps = PropsWithChildren<{ + instrument: { logoSrc: null | string; name: string; to: string }; + quote: ReactNode; +}>; + +const InstrumentSearchCard: FC = (props) => { + const { children, instrument, quote } = props; + + return ( + + + {instrument.name[0]} + + +
+ {instrument.name} + + {children} +
+ +
{quote}
+
+ ); +}; + +export { InstrumentSearchCard }; diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx b/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx index 5378658..8d114bc 100644 --- a/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx +++ b/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx @@ -1,24 +1,12 @@ import type { FC } from "react"; -import { - Alert, - Anchor, - Avatar, - Badge, - Button, - Group, - Paper, - Skeleton, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { LoadingSkeleton } from "@wealthledger/ui"; +import { Badge, Group, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { Link } from "react-router"; -import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; +import { + InstrumentSearch, + InstrumentSearchCard, +} from "@/features/instrument-search"; import { formatDate, formatDecimal, @@ -28,202 +16,101 @@ import { import type { BondsPageVm } from "./BondsPage.vm"; -import classes from "./BondsPage.module.css"; - type BondsPageViewProps = { vm: BondsPageVm }; -const BondsPageViewResults: FC = (props) => { - const { t } = useTranslation(); - const { vm } = props; - - if (vm.loading) { - return ( - - - - - - - - - - ); - } - - if (!vm.ready) return; - - if (vm.items.length > 0) { - return ( - - - {t("bond.searchVariants")} - - - {vm.items.map(bond => ( - - - {bond.name[0]} - - -
- - {bond.name} - - - - {bond.ticker} ·{" "} - {bond.perpetual - ? t("bond.perpetual") - : t("bond.matures", { - date: bond.maturityDate - ? formatDate(bond.maturityDate) - : t("bond.unknown"), - })} - - - - - {t(`stock.exchanges.${bond.realExchange}`, { - defaultValue: t("stock.exchangeUnknown"), - })} - - - {!!bond.classCode && ( - - {t("stock.classCode", { code: bond.classCode })} - - )} - - - {!!bond.isin && ( - - {t("stock.isin", { value: bond.isin })} - - )} - - - {t("bond.nominal")}: {formatMoney(bond.nominal)} ·{" "} - {t("bond.couponFrequency")}:{" "} - {bond.couponQuantityPerYear ?? t("bond.unknown")} - - - {!!bond.floatingCoupon && ( - - {t("bond.floatingCoupon")} - - )} - - {bond.apiTradeAvailable === false && ( - - {t("stock.apiUnavailable")} - - )} -
- -
- - {t("bond.percentOfNominal")} - - - - {bond.pricePercent === null - ? t("bond.unknown") - : `${formatDecimal(bond.pricePercent)} %`} - - - - {bond.pricePercent !== null && bond.priceAt - ? t("stock.priceAt", { - date: formatSnapshotTime(bond.priceAt), - }) - : t("stock.priceUnavailable")} - - - {bond.apiTradeAvailable === true && ( - - {t("stock.apiAvailable")} - - )} -
-
- ))} -
- ); - } - - return ( - - {t(vm.partial ? "investment.partial" : "bond.noSearchResults")} - - ); -}; - const BondsPageView: FC = (props) => { const { vm } = props; const { t } = useTranslation(); return ( - -
- {t("bond.searchTitle")} - - - {t("bond.searchSubtitle")} - -
- - - - {!!vm.partial && {t("investment.partial")}} - - - - {!vm.canSearch && {t("stock.searchHint")}} - - {!!vm.error && ( - - - - {t( - isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) - || isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken) - ? "bond.connect" - : "stock.sectionError", - )} + + {vm.items.map((bond) => { + const quote = ( + <> + + {t("bond.percentOfNominal")} - - {t("accounts.openSettings")} - + + {bond.pricePercent === null + ? t("bond.unknown") + : `${formatDecimal(bond.pricePercent)} %`} + - - - - )} + + {bond.pricePercent !== null && bond.priceAt + ? t("stock.priceAt", { + date: formatSnapshotTime(bond.priceAt), + }) + : t("stock.priceUnavailable")} + - -
+ {bond.apiTradeAvailable === true && ( + + {t("stock.apiAvailable")} + + )} + + ); + + return ( + + + {bond.ticker} ·{" "} + {bond.perpetual + ? t("bond.perpetual") + : t("bond.matures", { + date: bond.maturityDate + ? formatDate(bond.maturityDate) + : t("bond.unknown"), + })} + + + + + {t(`stock.exchanges.${bond.realExchange}`, { + defaultValue: t("stock.exchangeUnknown"), + })} + + + {!!bond.classCode && ( + + {t("stock.classCode", { code: bond.classCode })} + + )} + + + {!!bond.isin && ( + + {t("stock.isin", { value: bond.isin })} + + )} + + + {t("bond.nominal")}: {formatMoney(bond.nominal)} ·{" "} + {t("bond.couponFrequency")}:{" "} + {bond.couponQuantityPerYear ?? t("bond.unknown")} + + + {!!bond.floatingCoupon && ( + + {t("bond.floatingCoupon")} + + )} + + {bond.apiTradeAvailable === false && ( + + {t("stock.apiUnavailable")} + + )} + + ); + })} + ); }; diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts index d02b48f..a44a835 100644 --- a/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts +++ b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts @@ -1,25 +1,16 @@ -import type { ChangeEvent } from "react"; - -import { useDebouncedValue } from "@mantine/hooks"; -import { useSearchParams } from "react-router"; - import { MIN_BOND_SEARCH_LENGTH, useBondSearch } from "@/entities/bond"; +import { useInstrumentSearch } from "@/features/instrument-search"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; import { instrumentLogoUrl } from "@/shared/lib"; -const SEARCH_DEBOUNCE_MS = 300; - type BondsPageVm = ReturnType; function useBondsPageVm() { - const [parameters, setParameters] = useSearchParams(); - const query = parameters.get("q") ?? ""; - const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS); + const search = useInstrumentSearch(MIN_BOND_SEARCH_LENGTH); + const { canSearch, debounced, isWaiting, query } = search; const result = useBondSearch(debounced); const sync = useScreenSync({ query: debounced, screen: "bonds" }, [result]); - const canSearch = query.trim().length >= MIN_BOND_SEARCH_LENGTH; - const isWaiting = query.trim() !== debounced; return { canSearch, @@ -37,10 +28,7 @@ function useBondsPageVm() { || (!result.data && !result.isError && result.meta?.sync.state !== "failed")), - onQueryChange: (event: ChangeEvent) => { - const q = event.currentTarget.value; - setParameters(q ? { q } : {}, { replace: true }); - }, + onQueryChange: search.onQueryChange, partial: result.meta?.availability === "partial", query, ready: diff --git a/src/pages/card/ui/CardPage/CardPage.test.tsx b/src/pages/card/ui/CardPage/CardPage.test.tsx index 540b536..8140403 100644 --- a/src/pages/card/ui/CardPage/CardPage.test.tsx +++ b/src/pages/card/ui/CardPage/CardPage.test.tsx @@ -349,6 +349,12 @@ describe("CardPage", () => { it("archives without deleting history and restores financial actions", async () => { setup(); await screen.findByText("Перевод на «Запасная»"); + server.use(http.patch("*/api/v1/bank-cards/a", () => + HttpResponse.json({ code: "BANK_WRITE_CONFLICT" }, { status: 409 }), + )); + fireEvent.click(screen.getByRole("button", { name: "Убрать в архив" })); + await screen.findByRole("alert"); + expect(screen.getByRole("button", { name: "Добавить операцию" })).toBeEnabled(); let current = card; const bodies: unknown[] = []; server.use( @@ -376,6 +382,7 @@ describe("CardPage", () => { ); fireEvent.click(screen.getByRole("button", { name: "Убрать в архив" })); await screen.findByRole("button", { name: "Вернуть из архива" }); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect( screen.getByRole("button", { name: "Добавить операцию" }), ).toBeDisabled(); diff --git a/src/pages/card/ui/CardPage/CardPage.vm.ts b/src/pages/card/ui/CardPage/CardPage.vm.ts index fa8993e..e8b067c 100644 --- a/src/pages/card/ui/CardPage/CardPage.vm.ts +++ b/src/pages/card/ui/CardPage/CardPage.vm.ts @@ -34,7 +34,6 @@ function useCardPageVm() { const card = useBankCard(cardId); const updateCard = useUpdateBankCard(); - const [archiveError, setArchiveError] = useState(); const cards = useBankCards(true); const history = useBankOperations(cardId, { @@ -94,7 +93,7 @@ function useCardPageVm() { }); return { - archiveError, + archiveError: updateCard.isError ? updateCard.error : undefined, archivePending: updateCard.isPending, canAddOperation: !card.data?.archivedAt && Boolean(cards.data), card: card.data, @@ -149,19 +148,13 @@ function useCardPageVm() { loading: summary.isLoading, retry: () => void summary.refetch(), }, - toggleArchive: async () => { + toggleArchive: () => { if (!card.data) return; - setArchiveError(undefined); - try { - await updateCard.mutateAsync({ - body: { archived: !card.data.archivedAt, version: card.data.version }, - id: card.data.id, - }); - } - catch (error) { - setArchiveError(error); - } + updateCard.mutate({ + body: { archived: !card.data.archivedAt, version: card.data.version }, + id: card.data.id, + }); }, totalPages: pageSize === "all" diff --git a/src/pages/card/ui/OperationForm/OperationFields.vm.ts b/src/pages/card/ui/OperationForm/OperationFields.vm.ts index 53cc5dd..3477599 100644 --- a/src/pages/card/ui/OperationForm/OperationFields.vm.ts +++ b/src/pages/card/ui/OperationForm/OperationFields.vm.ts @@ -4,25 +4,17 @@ import { DateInput } from "@mantine/dates"; import { useController, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import type { OperationValues } from "./OperationForm.types"; +import type { + OperationFieldsProps, + OperationValues, +} from "./OperationForm.types"; -type Choice = { label: string; value: string }; - -type OperationFieldsProps = { - cards: Choice[]; - categories: Choice[]; - dateInputValue: (value: string) => string; - defaultCategory: string; - editing: boolean; - kind: OperationValues["kind"]; - kinds: Choice[]; - maxDate: () => string; - refundCardId: string; - targets: Choice[]; - validDate: (value: string) => boolean; -}; - -function useOperationFieldsVm(props: OperationFieldsProps) { +function useOperationFieldsVm(props: Pick) { const { t } = useTranslation(); const { diff --git a/src/pages/card/ui/OperationForm/OperationForm.types.ts b/src/pages/card/ui/OperationForm/OperationForm.types.ts index 09ba82e..ea4adad 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.types.ts +++ b/src/pages/card/ui/OperationForm/OperationForm.types.ts @@ -1,5 +1,21 @@ import type { BankCard, BankOperation } from "@/entities/bank-card"; +type Choice = { label: string; value: string }; + +type OperationFieldsProps = { + cards: Choice[]; + categories: Choice[]; + dateInputValue: (value: string) => string; + defaultCategory: string; + editing: boolean; + kind: OperationValues["kind"]; + kinds: Choice[]; + maxDate: () => string; + refundCardId: string; + targets: Choice[]; + validDate: (value: string) => boolean; +}; + type OperationFormProps = { card: BankCard; cards: BankCard[]; @@ -18,4 +34,9 @@ type OperationValues = { targetCardId: string; }; -export type { OperationFormProps, OperationValues }; +export type { + Choice, + OperationFieldsProps, + OperationFormProps, + OperationValues, +}; diff --git a/src/pages/card/ui/OperationForm/OperationForm.view.tsx b/src/pages/card/ui/OperationForm/OperationForm.view.tsx index 02db9f9..8fb73c4 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.view.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.view.tsx @@ -17,28 +17,12 @@ import type { BankOperation } from "@/entities/bank-card"; import { formatMoney } from "@/shared/lib"; -import type { OperationValues } from "./OperationForm.types"; +import type { Choice, OperationFieldsProps } from "./OperationForm.types"; import { useOperationFieldsVm } from "./OperationFields.vm"; import classes from "./OperationForm.module.css"; import { useRefundFieldsVm } from "./RefundFields.vm"; -type Choice = { label: string; value: string }; - -type OperationFieldsProps = { - cards: Choice[]; - categories: Choice[]; - dateInputValue: (value: string) => string; - defaultCategory: string; - editing: boolean; - kind: OperationValues["kind"]; - kinds: Choice[]; - maxDate: () => string; - refundCardId: string; - targets: Choice[]; - validDate: (value: string) => boolean; -}; - const OperationFields: FC = (props) => { const { cardIdField, diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.module.css b/src/pages/stocks/ui/StocksPage/StocksPage.module.css deleted file mode 100644 index ab07ae0..0000000 --- a/src/pages/stocks/ui/StocksPage/StocksPage.module.css +++ /dev/null @@ -1,36 +0,0 @@ -.card { - display: grid; - grid-template-columns: auto minmax(0, 1fr) minmax(150px, 210px); - gap: var(--mantine-spacing-md); - align-items: start; - padding: var(--mantine-spacing-lg); - border-radius: var(--mantine-radius-lg); - color: inherit; - text-decoration: none; -} -.card:hover { - border-color: var(--mantine-color-blue-4); - background: var(--mantine-color-blue-0); -} -.card:focus-visible { - outline: 2px solid var(--mantine-color-blue-6); - outline-offset: 3px; -} -.identity { - min-width: 0; - overflow-wrap: anywhere; -} -.quote { - text-align: right; - font-variant-numeric: tabular-nums; -} -@media (max-width: 40em) { - .card { - grid-template-columns: auto minmax(0, 1fr); - padding: var(--mantine-spacing-md); - } - .quote { - grid-column: 2; - text-align: left; - } -} diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx index edcb503..6056e71 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx +++ b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx @@ -1,202 +1,89 @@ import type { FC } from "react"; -import { - Alert, - Anchor, - Avatar, - Badge, - Button, - Group, - Paper, - Skeleton, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { LoadingSkeleton } from "@wealthledger/ui"; +import { Badge, Group, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { Link } from "react-router"; -import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; +import { + InstrumentSearch, + InstrumentSearchCard, +} from "@/features/instrument-search"; import { formatMoney, formatSnapshotTime } from "@/shared/lib"; import type { StocksPageVm } from "./StocksPage.vm"; -import classes from "./StocksPage.module.css"; - type StocksPageViewProps = { vm: StocksPageVm }; -const StocksPageViewResults: FC = (props) => { - const { t } = useTranslation(); - const { vm } = props; - - if (vm.loading) { - return ( - - - - - - - - - - ); - } - - if (!vm.ready) return; - - if (vm.items.length > 0) { - return ( - - - {t("stock.searchVariants")} - - - {vm.items.map(stock => ( - - - {stock.name[0]} - - -
- - {stock.name} - - - - {stock.ticker} ·{" "} - {t(`stock.shareTypes.${stock.shareType}`, { - defaultValue: t("stock.share"), - })} - - - - - {t(`stock.exchanges.${stock.realExchange}`, { - defaultValue: t("stock.exchangeUnknown"), - })} - - - {!!stock.classCode && ( - - {t("stock.classCode", { code: stock.classCode })} - - )} - - - {!!stock.isin && ( - - {t("stock.isin", { value: stock.isin })} - - )} - - {stock.apiTradeAvailable === false && ( - - {t("stock.apiUnavailable")} - - )} -
- -
- - {stock.price ? formatMoney(stock.price) : "—"} - - - - {stock.price && stock.priceAt - ? t("stock.priceAt", { - date: formatSnapshotTime(stock.priceAt), - }) - : t("stock.priceUnavailable")} - - - {stock.apiTradeAvailable === true && ( - - {t("stock.apiAvailable")} - - )} -
-
- ))} -
- ); - } - - return ( - - {t(vm.partial ? "investment.partial" : "stock.noSearchResults")} - - ); -}; - const StocksPageView: FC = (props) => { const { vm } = props; const { t } = useTranslation(); return ( - -
- {t("stock.searchTitle")} - - - {t("stock.searchSubtitle")} - -
- - - - {!!vm.partial && {t("investment.partial")}} - - - - {!vm.canSearch && {t("stock.searchHint")}} - - {!!vm.error && ( - - - - {t( - isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) - || isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken) - ? "stock.connect" - : "stock.sectionError", - )} + + {vm.items.map((stock) => { + const quote = ( + <> + + {stock.price ? formatMoney(stock.price) : "—"} - - {t("accounts.openSettings")} - + + {stock.price && stock.priceAt + ? t("stock.priceAt", { + date: formatSnapshotTime(stock.priceAt), + }) + : t("stock.priceUnavailable")} + - - - - )} + {stock.apiTradeAvailable === true && ( + + {t("stock.apiAvailable")} + + )} + + ); - -
+ return ( + + + {stock.ticker} ·{" "} + {t(`stock.shareTypes.${stock.shareType}`, { + defaultValue: t("stock.share"), + })} + + + + + {t(`stock.exchanges.${stock.realExchange}`, { + defaultValue: t("stock.exchangeUnknown"), + })} + + + {!!stock.classCode && ( + + {t("stock.classCode", { code: stock.classCode })} + + )} + + + {!!stock.isin && ( + + {t("stock.isin", { value: stock.isin })} + + )} + + {stock.apiTradeAvailable === false && ( + + {t("stock.apiUnavailable")} + + )} + + ); + })} + ); }; diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts index 5c6520c..5da6ec0 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts +++ b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts @@ -1,25 +1,16 @@ -import type { ChangeEvent } from "react"; - -import { useDebouncedValue } from "@mantine/hooks"; -import { useSearchParams } from "react-router"; - import { MIN_STOCK_SEARCH_LENGTH, useStockSearch } from "@/entities/stock"; +import { useInstrumentSearch } from "@/features/instrument-search"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; import { instrumentLogoUrl } from "@/shared/lib"; -const SEARCH_DEBOUNCE_MS = 300; - type StocksPageVm = ReturnType; function useStocksPageVm() { - const [parameters, setParameters] = useSearchParams(); - const query = parameters.get("q") ?? ""; - const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS); + const search = useInstrumentSearch(MIN_STOCK_SEARCH_LENGTH); + const { canSearch, debounced, isWaiting, query } = search; const result = useStockSearch(debounced); const sync = useScreenSync({ query: debounced, screen: "stocks" }, [result]); - const canSearch = query.trim().length >= MIN_STOCK_SEARCH_LENGTH; - const isWaiting = query.trim() !== debounced; return { canSearch, @@ -37,10 +28,7 @@ function useStocksPageVm() { || (!result.data && !result.isError && result.meta?.sync.state !== "failed")), - onQueryChange: (event: ChangeEvent) => { - const q = event.currentTarget.value; - setParameters(q ? { q } : {}, { replace: true }); - }, + onQueryChange: search.onQueryChange, partial: result.meta?.availability === "partial", query, ready: -- 2.54.0 From b404c1802a74f5f369173fdfcb086a3d5760e35a Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 29 Sep 2026 05:45:08 +0300 Subject: [PATCH 08/16] refactor: simplify auth and instrument pages --- src/app/routes/AppRoutes/AppRoutes.test.tsx | 29 ++- .../BankPeriodPicker/BankPeriodPicker.vm.ts | 2 +- src/entities/bond/api/queries.ts | 34 ++-- src/entities/bond/index.ts | 1 - src/entities/stock/api/queries.ts | 56 ++---- src/entities/stock/index.ts | 3 +- src/features/auth/model/auth-context.ts | 2 - src/features/auth/model/auth.test.tsx | 42 +++- src/features/auth/model/auth.vm.ts | 5 - src/features/auth/ui/LoginForm/LoginForm.tsx | 6 +- .../auth/ui/LoginForm/LoginForm.vm.ts | 28 --- .../auth/ui/RegisterForm/RegisterForm.tsx | 6 +- .../auth/ui/RegisterForm/RegisterForm.vm.ts | 28 --- src/features/instrument-details/index.ts | 3 + .../model/useInstrumentParameters.ts | 41 ++++ .../ui/InstrumentChart/InstrumentChart.vm.ts | 4 +- .../InstrumentHeader.module.css | 17 ++ .../InstrumentHeader.view.tsx | 45 +++++ .../ui/InstrumentPageState.view.tsx | 66 ++++++ src/pages/account/model/positions.fixtures.ts | 73 +++++++ src/pages/account/model/positions.test.ts | 189 ++++++++++++++++++ src/pages/account/model/positions.ts | 13 +- .../ui/AccountPage/AccountPage.module.css | 10 +- .../ui/AccountPage/AccountPage.test.tsx | 41 +--- .../ui/AccountPage/AccountPage.vm.test.tsx | 154 +------------- .../account/ui/AccountPage/AccountPage.vm.ts | 14 +- .../ui/AccountPage/position-groups.test.tsx | 115 +---------- .../bond/ui/BondPage/BondPage.module.css | 14 -- src/pages/bond/ui/BondPage/BondPage.tsx | 63 +----- src/pages/bond/ui/BondPage/BondPage.view.tsx | 41 +--- src/pages/bond/ui/BondPage/BondPage.vm.ts | 46 +---- .../stock/ui/StockPage/StockPage.module.css | 19 -- src/pages/stock/ui/StockPage/StockPage.tsx | 63 +----- .../stock/ui/StockPage/StockPage.view.tsx | 66 +++--- src/pages/stock/ui/StockPage/StockPage.vm.ts | 48 +---- ...vents.view.tsx => StockDividends.view.tsx} | 45 +++-- src/shared/api/index.ts | 1 - src/shared/api/resource-query.ts | 1 - 38 files changed, 644 insertions(+), 790 deletions(-) delete mode 100644 src/features/auth/ui/LoginForm/LoginForm.vm.ts delete mode 100644 src/features/auth/ui/RegisterForm/RegisterForm.vm.ts create mode 100644 src/features/instrument-details/model/useInstrumentParameters.ts create mode 100644 src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.module.css create mode 100644 src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.view.tsx create mode 100644 src/features/instrument-details/ui/InstrumentPageState.view.tsx create mode 100644 src/pages/account/model/positions.fixtures.ts create mode 100644 src/pages/account/model/positions.test.ts rename src/pages/stock/ui/StockPage/components/{StockEvents.view.tsx => StockDividends.view.tsx} (80%) diff --git a/src/app/routes/AppRoutes/AppRoutes.test.tsx b/src/app/routes/AppRoutes/AppRoutes.test.tsx index cbc1698..9a9ad22 100644 --- a/src/app/routes/AppRoutes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes/AppRoutes.test.tsx @@ -3,7 +3,7 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen } from "@testing-library/react"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { http, HttpResponse } from "msw"; -import { MemoryRouter } from "react-router"; +import { type InitialEntry, MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; import { AuthProvider } from "@/features/auth"; @@ -12,7 +12,7 @@ import { server } from "@/test/server"; import { AppRoutes } from "./AppRoutes"; -function renderRoutes(path: string) { +function renderRoutes(path: InitialEntry) { return render( @@ -57,10 +57,18 @@ describe("AppRoutes", () => { ).toBeInTheDocument(); }); - it("logs in through the public form and reaches the protected landing", async () => { + it.each([ + { endpoint: "login", path: "/accounts", title: "Вход" }, + { endpoint: "register", path: "/register", title: "Регистрация" }, + { + endpoint: "login", + path: { pathname: "/login", state: { returnTo: "//example.com" } }, + title: "Вход", + }, + ])("authenticates via $endpoint and reaches the protected landing from $path", async ({ endpoint, path, title }) => { let credentials: unknown; server.use( - http.post("*/api/v1/auth/login", async ({ request }) => { + http.post(`*/api/v1/auth/${endpoint}`, async ({ request }) => { credentials = await request.json(); return HttpResponse.json({ @@ -69,16 +77,23 @@ describe("AppRoutes", () => { }); }), ); - renderRoutes("/accounts"); + renderRoutes(path); - await screen.findByRole("heading", { level: 1, name: "Вход" }); + await screen.findByRole("heading", { level: 1, name: title }); fireEvent.change(screen.getByLabelText("Email"), { target: { value: " User@Example.com " }, }); fireEvent.change(screen.getByLabelText("Пароль"), { target: { value: "a secure password" }, }); - fireEvent.click(screen.getByRole("button", { name: "Вход" })); + + if (endpoint === "register") { + fireEvent.change(screen.getByLabelText("Подтвердите пароль"), { + target: { value: "a secure password" }, + }); + } + + fireEvent.click(screen.getByRole("button", { name: title })); expect( await screen.findByRole("heading", { diff --git a/src/entities/bank-card/ui/BankPeriodPicker/BankPeriodPicker.vm.ts b/src/entities/bank-card/ui/BankPeriodPicker/BankPeriodPicker.vm.ts index 245df2f..00b3b86 100644 --- a/src/entities/bank-card/ui/BankPeriodPicker/BankPeriodPicker.vm.ts +++ b/src/entities/bank-card/ui/BankPeriodPicker/BankPeriodPicker.vm.ts @@ -24,7 +24,7 @@ function useBankPeriodPickerVm(props: BankPeriodPickerProps) { onChange(next); }; - return { onChange, onPeriodChange, t, value }; + return { onPeriodChange, t, value }; } export { useBankPeriodPickerVm }; diff --git a/src/entities/bond/api/queries.ts b/src/entities/bond/api/queries.ts index e51100a..4755d6c 100644 --- a/src/entities/bond/api/queries.ts +++ b/src/entities/bond/api/queries.ts @@ -14,35 +14,28 @@ type BondPeriod = NonNullable< const BOND_STALE_TIME_MS = 30_000; -function bondQueryOptions(uid: string) { - return { - ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}", { - params: { path: { uid } }, - }), - staleTime: BOND_STALE_TIME_MS, - }; -} - function useBond(identifier: string) { const isUid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( identifier, ); - const byUid = useResourceQuery({ - ...bondQueryOptions(identifier), - enabled: Boolean(identifier) && isUid, - }); + // eslint-disable-next-line unicorn/prefer-minimal-ternary -- Hoisting params breaks the generated union of endpoint request shapes. + const init = isUid + ? { params: { path: { uid: identifier } } } + : { params: { path: { ticker: identifier } } }; - const byTicker = useResourceQuery({ - ...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", { - params: { path: { ticker: identifier } }, - }), - enabled: Boolean(identifier) && !isUid, + const options = apiQueryClient.queryOptions( + "get", + isUid ? "/api/v1/bonds/{uid}" : "/api/v1/bonds/by-ticker/{ticker}", + init, + ); + + return useResourceQuery({ + ...options, + enabled: Boolean(identifier), staleTime: BOND_STALE_TIME_MS, }); - - return isUid ? byUid : byTicker; } function useBondCandles(uid: string, period: BondPeriod) { @@ -110,7 +103,6 @@ function useBondSearch(query: string) { export { type BondCoupons, type BondMetrics, - type BondPeriod, MIN_BOND_SEARCH_LENGTH, useBond, useBondCandles, diff --git a/src/entities/bond/index.ts b/src/entities/bond/index.ts index 365bab9..dc839e1 100644 --- a/src/entities/bond/index.ts +++ b/src/entities/bond/index.ts @@ -1,7 +1,6 @@ export { type BondCoupons, type BondMetrics, - type BondPeriod, MIN_BOND_SEARCH_LENGTH, useBond, useBondCandles, diff --git a/src/entities/stock/api/queries.ts b/src/entities/stock/api/queries.ts index 969158e..51e52e6 100644 --- a/src/entities/stock/api/queries.ts +++ b/src/entities/stock/api/queries.ts @@ -1,13 +1,13 @@ import { apiQueryClient, type paths, - unwrapResourceResult, - useAuthenticatedQueries, useResourceQuery, } from "@/shared/api"; const MIN_STOCK_SEARCH_LENGTH = 2; +type StockDividends = NonNullable; + type StockPeriod = NonNullable< NonNullable< paths["/api/v1/stocks/{uid}/candles"]["get"]["parameters"]["query"] @@ -16,35 +16,28 @@ type StockPeriod = NonNullable< const STOCK_STALE_TIME_MS = 30_000; -function stockQueryOptions(uid: string) { - return { - ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}", { - params: { path: { uid } }, - }), - staleTime: STOCK_STALE_TIME_MS, - }; -} - function useStock(identifier: string) { const isUid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( identifier, ); - const byUid = useResourceQuery({ - ...stockQueryOptions(identifier), - enabled: Boolean(identifier) && isUid, - }); + // eslint-disable-next-line unicorn/prefer-minimal-ternary -- Hoisting params breaks the generated union of endpoint request shapes. + const init = isUid + ? { params: { path: { uid: identifier } } } + : { params: { path: { ticker: identifier } } }; - const byTicker = useResourceQuery({ - ...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", { - params: { path: { ticker: identifier } }, - }), - enabled: Boolean(identifier) && !isUid, + const options = apiQueryClient.queryOptions( + "get", + isUid ? "/api/v1/stocks/{uid}" : "/api/v1/stocks/by-ticker/{ticker}", + init, + ); + + return useResourceQuery({ + ...options, + enabled: Boolean(identifier), staleTime: STOCK_STALE_TIME_MS, }); - - return isUid ? byUid : byTicker; } function useStockCandles(uid: string, period: StockPeriod) { @@ -99,22 +92,6 @@ function useStockQuote(uid: string) { }); } -function useStocks(uids: string[]) { - const uniqueUids = [...new Set(uids)]; - - const queries = useAuthenticatedQueries( - uniqueUids.map(uniqueUid => stockQueryOptions(uniqueUid)), - ); - - return new Map( - queries.map((query, index) => [ - // eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. - uniqueUids[index], - unwrapResourceResult(query).data, - ]), - ); -} - function useStockSearch(query: string) { return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/stocks", { @@ -127,13 +104,12 @@ function useStockSearch(query: string) { export { MIN_STOCK_SEARCH_LENGTH, - type StockPeriod, + type StockDividends, useStock, useStockCandles, useStockCompany, useStockDividends, useStockPosition, useStockQuote, - useStocks, useStockSearch, }; diff --git a/src/entities/stock/index.ts b/src/entities/stock/index.ts index f3018b2..d449a38 100644 --- a/src/entities/stock/index.ts +++ b/src/entities/stock/index.ts @@ -1,12 +1,11 @@ export { MIN_STOCK_SEARCH_LENGTH, - type StockPeriod, + type StockDividends, useStock, useStockCandles, useStockCompany, useStockDividends, useStockPosition, useStockQuote, - useStocks, useStockSearch, } from "./api/queries"; diff --git a/src/features/auth/model/auth-context.ts b/src/features/auth/model/auth-context.ts index 083e5b2..1573a73 100644 --- a/src/features/auth/model/auth-context.ts +++ b/src/features/auth/model/auth-context.ts @@ -3,11 +3,9 @@ import { createContext, use } from "react"; import type { paths } from "@/shared/api"; type AuthContextValue = { - accessToken: string | undefined; login: (email: string, password: string) => Promise; logout: () => Promise; ready: boolean; - refresh: () => Promise; register: (email: string, password: string) => Promise; user: undefined | User; }; diff --git a/src/features/auth/model/auth.test.tsx b/src/features/auth/model/auth.test.tsx index 6d2fb65..571a0d0 100644 --- a/src/features/auth/model/auth.test.tsx +++ b/src/features/auth/model/auth.test.tsx @@ -27,6 +27,32 @@ function createAuthWrapper( ); } +async function expireSession() { + server.use(http.get("*/api/v1/brokerage-accounts", () => + HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }), + { once: true })); + + try { + await apiClient.GET("/api/v1/brokerage-accounts"); + } + catch (error) { + // A refresh that changes identity cancels the original user's request. + if (!(error instanceof DOMException && error.name === "AbortError")) throw error; + } +} + +async function requestAuthorization() { + let authorization: null | string = null; + server.use(http.get("*/api/v1/brokerage-accounts", ({ request }) => { + authorization = request.headers.get("Authorization"); + + return HttpResponse.json(resource([])); + }, { once: true })); + await apiClient.GET("/api/v1/brokerage-accounts"); + + return authorization; +} + describe("AuthProvider", () => { it("authorizes requests after restoring the initial session", async () => { let authorization: null | string @@ -77,7 +103,7 @@ describe("AuthProvider", () => { await waitFor(() => expect(result.current.ready).toBe(true)); expect(result.current.user).toBeUndefined(); - expect(result.current.accessToken).toBeUndefined(); + expect(await requestAuthorization()).toBeNull(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); }); @@ -106,13 +132,13 @@ describe("AuthProvider", () => { const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => - expect(result.current.accessToken).toBe("restored-token"), + expect(result.current.ready).toBe(true), ); const response = await apiClient.GET("/api/v1/brokerage-accounts"); expect(response.response.status).toBe(401); await waitFor(() => expect(result.current.user).toBeUndefined()); - expect(result.current.accessToken).toBeUndefined(); + expect(await requestAuthorization()).toBeNull(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); }); @@ -147,7 +173,7 @@ describe("AuthProvider", () => { queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); const wrapper: FC = createAuthWrapper(queryClient); const { result } = renderHook(() => useAuth(), { wrapper }); - await waitFor(() => expect(result.current.accessToken).toBe("test-token")); + await waitFor(() => expect(result.current.ready).toBe(true)); expect(queryClient.getQueryData(bankCardsKey)).toEqual([ { id: "bank-card-1" }, @@ -155,7 +181,7 @@ describe("AuthProvider", () => { await act(() => result.current.logout()); expect(result.current.user).toBeUndefined(); - expect(result.current.accessToken).toBeUndefined(); + expect(await requestAuthorization()).toBeNull(); expect(queryClient.getQueryData(statusKey)).toBeUndefined(); expect(queryClient.getQueryData(portfolioKey)).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); @@ -265,7 +291,7 @@ describe("AuthProvider", () => { const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.ready).toBe(true)); queryClient.setQueryData(bankCardsKey, [{ id: "a-card" }]); - await act(() => result.current.refresh()); + await act(() => expireSession()); expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]); let release!: (value: string) => void; @@ -283,7 +309,7 @@ describe("AuthProvider", () => { .catch(() => {}); identity = "b"; - await act(() => result.current.refresh()); + await act(() => expireSession()); release("a-data"); await pending; expect(result.current.user?.id).toBe("b"); @@ -322,7 +348,7 @@ describe("AuthProvider", () => { expect(result.current.cards.data?.[0].id).toBe("a-card"), ); identity = "b"; - await act(() => result.current.auth.refresh()); + await act(() => expireSession()); await waitFor(() => expect(result.current.cards.data?.[0].id).toBe("b-card"), ); diff --git a/src/features/auth/model/auth.vm.ts b/src/features/auth/model/auth.vm.ts index cb757bd..4a6906e 100644 --- a/src/features/auth/model/auth.vm.ts +++ b/src/features/auth/model/auth.vm.ts @@ -29,7 +29,6 @@ async function request( function useAuthProviderVm() { const queryClient = useQueryClient(); const [user, setUser] = useState(undefined); - const [accessToken, setAccessToken] = useState(undefined); const accessTokenRef = useRef(undefined); const [ready, setReady] = useState(false); @@ -69,7 +68,6 @@ function useAuthProviderVm() { userRef.current = authentication.user; setUser(authentication.user); - setAccessToken(authentication.accessToken); }, [queryClient], ); @@ -78,7 +76,6 @@ function useAuthProviderVm() { generationRef.current += 1; userRef.current = undefined; setUser(undefined); - setAccessToken(undefined); accessTokenRef.current = undefined; queryClient.clear(); }, [queryClient]); @@ -156,12 +153,10 @@ function useAuthProviderVm() { useEffect(() => () => configureAuthClient(), []); const authContextValue = { - accessToken, login: (email: string, password: string) => authenticate("/api/v1/auth/login", email, password), logout, ready, - refresh, register: (email: string, password: string) => authenticate("/api/v1/auth/register", email, password), user, diff --git a/src/features/auth/ui/LoginForm/LoginForm.tsx b/src/features/auth/ui/LoginForm/LoginForm.tsx index d3a6cea..d9696c2 100644 --- a/src/features/auth/ui/LoginForm/LoginForm.tsx +++ b/src/features/auth/ui/LoginForm/LoginForm.tsx @@ -1,12 +1,12 @@ import type { FC } from "react"; +import { useAuth } from "../../model/auth-context"; import { AuthForm } from "../AuthForm/AuthForm"; -import { useLoginFormVm } from "./LoginForm.vm"; const LoginForm: FC = () => { - const { onSubmit } = useLoginFormVm(); + const { login } = useAuth(); - return ; + return ; }; export { LoginForm }; diff --git a/src/features/auth/ui/LoginForm/LoginForm.vm.ts b/src/features/auth/ui/LoginForm/LoginForm.vm.ts deleted file mode 100644 index 0392611..0000000 --- a/src/features/auth/ui/LoginForm/LoginForm.vm.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ComponentProps } from "react"; - -import { useLocation, useNavigate } from "react-router"; - -import { useAuth } from "../../model/auth-context"; -import { AuthForm } from "../AuthForm/AuthForm"; - -function useLoginFormVm() { - const { login } = useAuth(); - const navigate = useNavigate(); - const location = useLocation(); - - const returnTo - = (location.state as null - | { returnTo?: string })?.returnTo ?? "/accounts"; - - const onSubmit: ComponentProps["onSubmit"] = async ( - email, - password, - ) => { - await login(email, password); - await navigate(returnTo, { replace: true }); - }; - - return { onSubmit }; -} - -export { useLoginFormVm }; diff --git a/src/features/auth/ui/RegisterForm/RegisterForm.tsx b/src/features/auth/ui/RegisterForm/RegisterForm.tsx index 9832dd1..f293501 100644 --- a/src/features/auth/ui/RegisterForm/RegisterForm.tsx +++ b/src/features/auth/ui/RegisterForm/RegisterForm.tsx @@ -1,12 +1,12 @@ import type { FC } from "react"; +import { useAuth } from "../../model/auth-context"; import { AuthForm } from "../AuthForm/AuthForm"; -import { useRegisterFormVm } from "./RegisterForm.vm"; const RegisterForm: FC = () => { - const { onSubmit } = useRegisterFormVm(); + const { register } = useAuth(); - return ; + return ; }; export { RegisterForm }; diff --git a/src/features/auth/ui/RegisterForm/RegisterForm.vm.ts b/src/features/auth/ui/RegisterForm/RegisterForm.vm.ts deleted file mode 100644 index 35a3235..0000000 --- a/src/features/auth/ui/RegisterForm/RegisterForm.vm.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ComponentProps } from "react"; - -import { useLocation, useNavigate } from "react-router"; - -import { useAuth } from "../../model/auth-context"; -import { AuthForm } from "../AuthForm/AuthForm"; - -function useRegisterFormVm() { - const { register } = useAuth(); - const navigate = useNavigate(); - const location = useLocation(); - - const returnTo - = (location.state as null - | { returnTo?: string })?.returnTo ?? "/accounts"; - - const onSubmit: ComponentProps["onSubmit"] = async ( - email, - password, - ) => { - await register(email, password); - await navigate(returnTo, { replace: true }); - }; - - return { onSubmit, register }; -} - -export { useRegisterFormVm }; diff --git a/src/features/instrument-details/index.ts b/src/features/instrument-details/index.ts index 556c6fa..f1cf034 100644 --- a/src/features/instrument-details/index.ts +++ b/src/features/instrument-details/index.ts @@ -1,5 +1,8 @@ export { section, type SectionState } from "./model/section"; +export { useInstrumentParameters } from "./model/useInstrumentParameters"; export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view"; export { InstrumentEvents } from "./ui/InstrumentEvents.view"; +export { InstrumentHeader } from "./ui/InstrumentHeader/InstrumentHeader.view"; +export { InstrumentPageState } from "./ui/InstrumentPageState.view"; export { InstrumentPosition } from "./ui/InstrumentPosition.view"; export { InstrumentSection } from "./ui/InstrumentSection.view"; diff --git a/src/features/instrument-details/model/useInstrumentParameters.ts b/src/features/instrument-details/model/useInstrumentParameters.ts new file mode 100644 index 0000000..e622739 --- /dev/null +++ b/src/features/instrument-details/model/useInstrumentParameters.ts @@ -0,0 +1,41 @@ +import { useSearchParams } from "react-router"; + +import type { OperationCategory } from "@/entities/broker-operation"; + +const instrumentPeriods = ["month", "halfYear", "year", "all"] as const; + +function useInstrumentParameters(incomeFilter: "coupons" | "dividends") { + const [parameters, setParameters] = useSearchParams(); + + const period = instrumentPeriods.find( + value => value === parameters.get("period"), + ) ?? "month"; + + const filters: { categories: OperationCategory[]; value: string }[] = [ + { categories: [], value: "all" }, + { categories: ["trade"], value: "trades" }, + { categories: ["income"], value: incomeFilter }, + { categories: ["tax", "fee", "transfer", "other"], value: "other" }, + ]; + + const { categories, value: filter } = filters.find( + item => item.value === parameters.get("events"), + ) ?? filters[0]; + + const setParameter = (key: string, value: string) => { + const next = new URLSearchParams(parameters); + next.set(key, value); + setParameters(next, { replace: true }); + }; + + return { + categories, + filter, + period, + search: parameters.size > 0 ? `?${parameters.toString()}` : "", + setFilter: (value: string) => setParameter("events", value), + setPeriod: (value: string) => setParameter("period", value), + }; +} + +export { instrumentPeriods, useInstrumentParameters }; diff --git a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts index 885c383..8cbc580 100644 --- a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts +++ b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts @@ -4,6 +4,8 @@ import { formatDate, formatDecimal, formatMoney } from "@/shared/lib"; import type { InstrumentChartProps } from "../../model/types"; +import { instrumentPeriods } from "../../model/useInstrumentParameters"; + function useInstrumentChartVm(props: InstrumentChartProps) { const { vm } = props; const { t } = useTranslation(); @@ -34,7 +36,7 @@ function useInstrumentChartVm(props: InstrumentChartProps) { return { ...candle, price }; }); - const periodOptions = ["month", "halfYear", "year", "all"].map(value => ({ + const periodOptions = instrumentPeriods.map(value => ({ label: t(`stock.periods.${value}`), value, })); diff --git a/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.module.css b/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.module.css new file mode 100644 index 0000000..0ee88f3 --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.module.css @@ -0,0 +1,17 @@ +.header { + padding: var(--mantine-spacing-lg); + border-radius: var(--mantine-radius-lg); + min-width: 0; + overflow-wrap: anywhere; +} +.identity { + display: flex; + align-items: center; + gap: var(--mantine-spacing-md); + min-width: 0; +} +@media (max-width: 48em) { + .header { + padding: var(--mantine-spacing-md); + } +} diff --git a/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.view.tsx b/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.view.tsx new file mode 100644 index 0000000..8a260e1 --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.view.tsx @@ -0,0 +1,45 @@ +import type { FC, PropsWithChildren, ReactNode } from "react"; + +import { Avatar, Button, Group, Paper, Text, Title } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +import classes from "./InstrumentHeader.module.css"; + +type InstrumentHeaderProps = PropsWithChildren<{ + logoSrc: null | string; + name: string; + onRefresh: () => void; + refreshing: boolean; + subtitle: ReactNode; +}>; + +const InstrumentHeader: FC = (props) => { + const { children, logoSrc, name, onRefresh, refreshing, subtitle } = props; + const { t } = useTranslation(); + + return ( + + +
+ + {name[0]} + + +
+ {name} + + {subtitle} +
+
+ + +
+ + {children} +
+ ); +}; + +export { InstrumentHeader }; diff --git a/src/features/instrument-details/ui/InstrumentPageState.view.tsx b/src/features/instrument-details/ui/InstrumentPageState.view.tsx new file mode 100644 index 0000000..aa6fb61 --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentPageState.view.tsx @@ -0,0 +1,66 @@ +import type { FC } from "react"; + +import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; + +import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; + +type InstrumentPageStateProps = { + code?: string; + kind: "bond" | "stock"; + loading: boolean; + retry: () => void; +}; + +const InstrumentPageState: FC = (props) => { + const { code, kind, loading, retry } = props; + const { t } = useTranslation(); + if (loading) return ( + + + + + + + + + + ); + + const ambiguousCode = kind === "stock" + ? ApiErrorCode.StockTickerAmbiguous + : ApiErrorCode.BondTickerAmbiguous; + + let message = t(`${kind}.unavailableText`); + if (isApiErrorCode(code, ambiguousCode)) + message = t(`${kind}.ambiguousTicker`); + if ( + isApiErrorCode(code, ApiErrorCode.TbankNotConnected) + || isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) + ) + message = t(`${kind}.connect`); + + return ( + + {t(`${kind}.unavailable`)} + + {message} + + + {t(`${kind}.backToSearch`)} + + + + {t("accounts.openSettings")} + + + + + ); +}; + +export { InstrumentPageState }; diff --git a/src/pages/account/model/positions.fixtures.ts b/src/pages/account/model/positions.fixtures.ts new file mode 100644 index 0000000..6b02780 --- /dev/null +++ b/src/pages/account/model/positions.fixtures.ts @@ -0,0 +1,73 @@ +import type { Portfolio } from "@/entities/portfolio"; + +const portfolio: Portfolio + = { + cashBalances: [{ currency: "USD", value: "1" }], + isStale: true, + lastSuccessfulAt: "2026-08-13T10:00:00Z", + positions: [ + { + absoluteReturn: { currency: "RUB", value: "10" }, + averagePurchasePrice: null, + currentValue: { currency: "RUB", value: "100" }, + displayName: "Акция", + figi: "FIGI-STOCK", + isin: null, + logoName: "akciya.png", + quantity: "2", + ticker: null, + type: "STOCK", + }, + { + absoluteReturn: { currency: "RUB", value: "-5" }, + averagePurchasePrice: { currency: "RUB", value: "45" }, + currentValue: { currency: "RUB", value: "50" }, + displayName: "Облигация", + figi: "FIGI-BOND", + isin: "RU000A0JX0J2", + logoName: null, + quantity: "1", + ticker: null, + type: "BOND", + }, + { + absoluteReturn: null, + averagePurchasePrice: null, + currentValue: null, + displayName: "Фонд", + figi: "FIGI-FUND", + isin: null, + logoName: "fond.png", + quantity: "3", + ticker: null, + type: "FUND", + }, + ], + structure: { currencies: [], unvaluedCategories: [] }, + totalsByCurrency: [{ currency: "RUB", value: "150" }], + }; + +const position: Portfolio["positions"][number] = { + ...portfolio.positions[0], + absoluteReturn: { currency: "RUB", value: "25" }, + account: { id: "a", name: "Основной" }, + averagePurchasePrice: { currency: "RUB", value: "280" }, + currentValue: { currency: "RUB", value: "375" }, + displayName: "Сбербанк", + figi: "FIGI-SBER", + isin: "RU0009029540", + logoName: null, + quantity: "1.25", + ticker: "SBER", +}; + +const second = { + ...position, + absoluteReturn: null, + account: { id: "b", name: "ИИС" }, + averagePurchasePrice: null, + currentValue: null, + quantity: "-2", +}; + +export { portfolio, position, second }; diff --git a/src/pages/account/model/positions.test.ts b/src/pages/account/model/positions.test.ts new file mode 100644 index 0000000..d39d659 --- /dev/null +++ b/src/pages/account/model/positions.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; + +import type { Portfolio } from "@/entities/portfolio"; + +import { toAccountPagePositionSections } from "./positions"; +import { portfolio, position, second } from "./positions.fixtures"; + +function sections(positions: Portfolio["positions"]) { + return toAccountPagePositionSections({ + ...portfolio, + positions, + }); +} + +describe("account position sections", () => { + it("excludes currency instruments only from display, preserving order and share denominators", () => { + const otherTypes = [ + "OPTION", + "OTHER", + "FUTURE", + "STRUCTURED_NOTE", + "DIGITAL_FINANCIAL_ASSET", + "FUND", + ] as const; + + const snapshot: Portfolio = { + ...portfolio, + cashBalances: [{ currency: "RUB", value: "1000" }], + positions: [ + ...portfolio.positions, + { + ...portfolio.positions[0], + currentValue: { currency: "RUB", value: "50" }, + figi: "FIGI-CURRENCY", + type: "CURRENCY", + }, + ...otherTypes.map( + + type => + ({ + ...portfolio.positions[2], + currentValue: + type === "OPTION" ? { currency: "RUB", value: "50" } : null, + figi: `FIGI-${type}-NEXT`, + type, + }), + ), + ], + structure: { currencies: [], unvaluedCategories: [] }, + totalsByCurrency: [{ currency: "RUB", value: "1250" }], + }; + + const original = structuredClone(snapshot); + + const sections = toAccountPagePositionSections(snapshot); + + expect(sections.map(section => section.kind)).toEqual([ + "stocks", + "bonds", + "other", + ]); + expect(sections[2].groups.map(group => group.positions[0].figi)).toEqual([ + "FIGI-FUND", + ...otherTypes.map(type => `FIGI-${type}-NEXT`), + ]); + expect(sections[0].groups[0].positions[0].shareText).toBe("40 %"); + expect(sections[1].groups[0].positions[0].shareText).toBe("20 %"); + expect(sections[2].groups[1].positions[0].shareText).toBe("20 %"); + expect(snapshot).toEqual(original); + }); + + it("prepares grouped position sections with derived display data", () => { + expect( + toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({ + kind, + positions: groups.flatMap(group => group.positions), + })), + ).toMatchObject([ + { + kind: "stocks", + positions: [ + { + displayName: "Акция", + logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png", + returnText: "+10 ₽", + returnTone: "positive", + shareText: "66,7 %", + }, + ], + + }, + { + kind: "bonds", + positions: [ + { + displayName: "Облигация", + logoSrc: null, + returnText: "-5 ₽", + returnTone: "negative", + shareText: "33,3 %", + }, + ], + }, + { + kind: "other", + positions: [ + { + displayName: "Фонд", + logoSrc: null, + returnText: null, + returnTone: undefined, + shareText: null, + }, + ], + }, + ]); + }); + + it("uses ISIN only when FIGI is absent, never merging by name or ticker", () => { + expect( + + sections([ + { + ...position, + figi: null, + }, + { ...second, figi: null }, + ])[0].groups, + ).toHaveLength(1); + expect( + sections([position, { ...second, figi: "OTHER-FIGI" }])[0].groups, + ).toHaveLength(2); + expect( + + sections([ + { + ...position, + figi: null, + isin: null, + }, + { + ...second, + figi: null, + isin: null, + }, + ])[0].groups, + ).toHaveLength(2); + }); + + it("preserves one-account cards and does not merge repeated rows on the same account", () => { + expect( + sections([position, { ...second, account: position.account }])[0].groups, + ).toHaveLength(2); + expect( + sections([ + { ...position, account: undefined }, + { ...second, account: undefined }, + ])[0].groups, + ).toHaveLength(2); + }); + + it("keeps currencies and purchase prices attached to their source positions", () => { + const source = [ + position, + { + ...second, + averagePurchasePrice: { currency: "USD", value: "0.99" }, + currentValue: { currency: "USD", value: "1.01" }, + }, + ]; + + const before = structuredClone(source); + const [group] = sections(source)[0].groups; + expect( + group.positions.map(item => [ + item.quantity, + item.currentValue, + item.averagePurchasePrice, + ]), + ).toEqual( + source.map(item => [ + item.quantity, + item.currentValue, + item.averagePurchasePrice, + ]), + ); + expect(source).toEqual(before); + }); +}); diff --git a/src/pages/account/model/positions.ts b/src/pages/account/model/positions.ts index 347e215..340f36a 100644 --- a/src/pages/account/model/positions.ts +++ b/src/pages/account/model/positions.ts @@ -69,8 +69,6 @@ function positionIdentity(position: AccountPagePosition) { function toAccountPagePositionSections( portfolio: Portfolio, - instrumentDetails: Map = new Map(), ): AccountPagePositionSection[] { const denominators = new Map(); @@ -87,11 +85,10 @@ function toAccountPagePositionSections( "stocks", portfolio, denominators, - instrumentDetails, ); - const bonds = toSection("bonds", portfolio, denominators, instrumentDetails); - const other = toSection("other", portfolio, denominators, instrumentDetails); + const bonds = toSection("bonds", portfolio, denominators); + const other = toSection("other", portfolio, denominators); return [stocks, bonds, other].filter(section => section.groups.length > 0); } @@ -100,7 +97,6 @@ function toPosition( position: Portfolio["positions"][number], denominators: Map, kind: PositionSectionKind, - instrumentSlug?: string, ): AccountPagePosition { const isExpanded = kind !== "other"; @@ -119,7 +115,7 @@ function toPosition( ...position, instrumentTo: (kind === "stocks" || kind === "bonds") && position.instrumentUid - ? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}` + ? `/${kind}/${encodeURIComponent(position.instrumentUid)}` : undefined, logoSrc: isExpanded ? instrumentLogoUrl(position.logoName) @@ -141,8 +137,6 @@ function toSection( kind: PositionSectionKind, portfolio: Portfolio, denominators: Map, - instrumentDetails: Map, ): AccountPagePositionSection { return { groups: groupPositions( @@ -161,7 +155,6 @@ function toSection( position, denominators, kind, - instrumentDetails.get(position.instrumentUid ?? "")?.slug, ), ), ), diff --git a/src/pages/account/ui/AccountPage/AccountPage.module.css b/src/pages/account/ui/AccountPage/AccountPage.module.css index c097c31..9c0e130 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.module.css +++ b/src/pages/account/ui/AccountPage/AccountPage.module.css @@ -1,5 +1,4 @@ -.tile, -.cash { +.tile { border-color: var(--mantine-color-gray-3); background: var(--mantine-color-gray-0); } @@ -52,15 +51,10 @@ margin-block: 5px; } -.tile, -.cash { +.tile { padding: var(--mantine-spacing-md); } -.cash { - display: flex; - justify-content: space-between; -} .positive { color: var(--wl-color-positive); diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index ca410bb..52742a7 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -212,10 +212,10 @@ describe("AccountPage states", () => { for (const link of links) expect(link).toHaveAttribute( "href", - `/${collection}/${type === "STOCK" ? slug : uid}`, + `/${collection}/${uid}`, ); }); - expect(requests).toHaveBeenCalledTimes(type === "STOCK" ? 1 : 0); + expect(requests).not.toHaveBeenCalled(); expect( screen.queryByText("Название из карточки инструмента"), ).not.toBeInTheDocument(); @@ -234,43 +234,6 @@ describe("AccountPage states", () => { }, ); - it.each(["unavailable", "noncanonical"])( - "keeps exact UID links and the portfolio visible when ticker is %s", - async (scenario) => { - const uid = "c190ff1f-1447-4227-b543-316332699ca5"; - const requests = vi.fn(); - server.use( - http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => - HttpResponse.json( - resource({ - ...portfolio, - positions: [ - { - ...portfolio.positions[0], - instrumentUid: uid, - ticker: "SBERP", - }, - ], - }), - ), - ), - http.get("*/api/v1/stocks/:uid", () => { - requests(); - - return scenario === "unavailable" - ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) - : HttpResponse.json(resource({ instrumentUid: uid, slug: uid })); - }), - ); - renderAccountRoute(createTestQueryClient()); - await waitFor(() => expect(requests).toHaveBeenCalledOnce()); - const links = await screen.findAllByRole("link", { name: "Вторая" }); - expect(links).toHaveLength(2); - for (const link of links) - expect(link).toHaveAttribute("href", `/stocks/${uid}`); - }, - ); - it("keeps the account and positions visible for an older HTTP response without structure", async () => { server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx index c5ca484..091d099 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx @@ -6,63 +6,14 @@ import { http, HttpResponse } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; import { describe, expect, it, vi } from "vitest"; -import type { Portfolio } from "@/entities/portfolio"; - import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; -import { toAccountPagePositionSections } from "../../model/positions"; +import { portfolio } from "../../model/positions.fixtures"; import { useAccountPageViewModel } from "./AccountPage.vm"; -const portfolio: Portfolio - = { - cashBalances: [{ currency: "USD", value: "1" }], - isStale: true, - lastSuccessfulAt: "2026-08-13T10:00:00Z", - positions: [ - { - absoluteReturn: { currency: "RUB", value: "10" }, - averagePurchasePrice: null, - currentValue: { currency: "RUB", value: "100" }, - displayName: "Акция", - figi: "FIGI-STOCK", - isin: null, - logoName: "akciya.png", - quantity: "2", - ticker: null, - type: "STOCK", - }, - { - absoluteReturn: { currency: "RUB", value: "-5" }, - averagePurchasePrice: { currency: "RUB", value: "45" }, - currentValue: { currency: "RUB", value: "50" }, - displayName: "Облигация", - figi: "FIGI-BOND", - isin: "RU000A0JX0J2", - logoName: null, - quantity: "1", - ticker: null, - type: "BOND", - }, - { - absoluteReturn: null, - averagePurchasePrice: null, - currentValue: null, - displayName: "Фонд", - figi: "FIGI-FUND", - isin: null, - logoName: "fond.png", - quantity: "3", - ticker: null, - type: "FUND", - }, - ], - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [{ currency: "RUB", value: "150" }], - }; - function configureTestAuth() { configureAuthClient({ accessToken: () => "test-token", @@ -152,109 +103,6 @@ function mockLoadedAccountPage() { } describe("useAccountPageViewModel", () => { - it("excludes currency instruments only from display, preserving order and share denominators", () => { - const otherTypes = [ - "OPTION", - "OTHER", - "FUTURE", - "STRUCTURED_NOTE", - "DIGITAL_FINANCIAL_ASSET", - "FUND", - ] as const; - - const snapshot: Portfolio = { - ...portfolio, - cashBalances: [{ currency: "RUB", value: "1000" }], - positions: [ - ...portfolio.positions, - { - ...portfolio.positions[0], - currentValue: { currency: "RUB", value: "50" }, - figi: "FIGI-CURRENCY", - type: "CURRENCY", - }, - ...otherTypes.map( - - type => - ({ - ...portfolio.positions[2], - currentValue: - type === "OPTION" ? { currency: "RUB", value: "50" } : null, - figi: `FIGI-${type}-NEXT`, - type, - }), - ), - ], - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [{ currency: "RUB", value: "1250" }], - }; - - const original = structuredClone(snapshot); - - const sections = toAccountPagePositionSections(snapshot); - - expect(sections.map(section => section.kind)).toEqual([ - "stocks", - "bonds", - "other", - ]); - expect(sections[2].groups.map(group => group.positions[0].figi)).toEqual([ - "FIGI-FUND", - ...otherTypes.map(type => `FIGI-${type}-NEXT`), - ]); - expect(sections[0].groups[0].positions[0].shareText).toBe("40 %"); - expect(sections[1].groups[0].positions[0].shareText).toBe("20 %"); - expect(sections[2].groups[1].positions[0].shareText).toBe("20 %"); - expect(snapshot).toEqual(original); - }); - - it("prepares grouped position sections with derived display data", () => { - expect( - toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({ - kind, - positions: groups.flatMap(group => group.positions), - })), - ).toMatchObject([ - { - kind: "stocks", - positions: [ - { - displayName: "Акция", - logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png", - returnText: "+10 ₽", - returnTone: "positive", - shareText: "66,7 %", - }, - ], - - }, - { - kind: "bonds", - positions: [ - { - displayName: "Облигация", - logoSrc: null, - returnText: "-5 ₽", - returnTone: "negative", - shareText: "33,3 %", - }, - ], - }, - { - kind: "other", - positions: [ - { - displayName: "Фонд", - logoSrc: null, - returnText: null, - returnTone: undefined, - shareText: null, - }, - ], - }, - ]); - }); - it("prepares recent operations and events state for presentation views", async () => { configureTestAuth(); mockLoadedAccountPage(); diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 6f35f44..69fb200 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -14,7 +14,6 @@ import { } from "@/entities/brokerage-event"; import { type IncomeSummary, useIncomeSummary } from "@/entities/income"; import { usePortfolio } from "@/entities/portfolio"; -import { useStocks } from "@/entities/stock"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; @@ -168,14 +167,6 @@ function useAccountPageViewModel(): AccountPageViewModel { const { accountId } = useParams(); const portfolio = usePortfolio(accountId); - const stocks = useStocks( - portfolio.data?.positions.flatMap(position => - position.type === "STOCK" && position.instrumentUid - ? [position.instrumentUid] - : [], - ) ?? [], - ); - const operations = useBrokerageOperations(accountId, { limit: 5, }); @@ -236,10 +227,7 @@ function useAccountPageViewModel(): AccountPageViewModel { accountId, incomeSummary, portfolio: portfolio.data, - positionSections: toAccountPagePositionSections( - portfolio.data, - stocks, - ), + positionSections: toAccountPagePositionSections(portfolio.data), recentEvents, recentOperations, state: "loaded" as const, diff --git a/src/pages/account/ui/AccountPage/position-groups.test.tsx b/src/pages/account/ui/AccountPage/position-groups.test.tsx index 4875400..db760bb 100644 --- a/src/pages/account/ui/AccountPage/position-groups.test.tsx +++ b/src/pages/account/ui/AccountPage/position-groups.test.tsx @@ -3,50 +3,16 @@ import { render, screen, within } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; -import type { Portfolio } from "@/entities/portfolio"; - import { toAccountPagePositionSections } from "../../model/positions"; +import { portfolio, position, second } from "../../model/positions.fixtures"; import { PositionSection } from "./components/PositionSection.view"; -const position: Portfolio["positions"][number] - = { - absoluteReturn: { currency: "RUB", value: "25" }, - account: { id: "a", name: "Основной" }, - averagePurchasePrice: { currency: "RUB", value: "280" }, - currentValue: { currency: "RUB", value: "375" }, - displayName: "Сбербанк", - figi: "FIGI-SBER", - isin: "RU0009029540", - logoName: null, - quantity: "1.25", - ticker: "SBER", - type: "STOCK", - }; - -const second - = { - ...position, - absoluteReturn: null, - account: { id: "b", name: "ИИС" }, - averagePurchasePrice: null, - currentValue: null, - quantity: "-2", - }; - -function sections(positions: Portfolio["positions"]) { - return toAccountPagePositionSections({ - cashBalances: [], - isStale: false, - lastSuccessfulAt: "2026-09-09T12:00:00Z", - positions, - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [], - }); -} - describe("Positions grouped by security", () => { it("shows one security with exact per-account quantities and valuations", () => { - const section = sections([position, second])[0]; + const section = toAccountPagePositionSections({ + ...portfolio, positions: [position, second], + })[0]; + render( @@ -76,75 +42,4 @@ describe("Positions grouped by security", () => { expect(within(rows[1]).getByText("Количество: -2")).toBeInTheDocument(); expect(rows[1]).not.toHaveTextContent("₽"); }); - - it("uses ISIN only when FIGI is absent, never merging by name or ticker", () => { - expect( - - sections([ - { - ...position, - figi: null, - }, - { ...second, figi: null }, - ])[0].groups, - ).toHaveLength(1); - expect( - sections([position, { ...second, figi: "OTHER-FIGI" }])[0].groups, - ).toHaveLength(2); - expect( - - sections([ - { - ...position, - figi: null, - isin: null, - }, - { - ...second, - figi: null, - isin: null, - }, - ])[0].groups, - ).toHaveLength(2); - }); - - it("preserves one-account cards and does not merge repeated rows on the same account", () => { - expect( - sections([position, { ...second, account: position.account }])[0].groups, - ).toHaveLength(2); - expect( - sections([ - { ...position, account: undefined }, - { ...second, account: undefined }, - ])[0].groups, - ).toHaveLength(2); - }); - - it("keeps currencies and purchase prices attached to their source positions", () => { - const source = [ - position, - { - ...second, - averagePurchasePrice: { currency: "USD", value: "0.99" }, - currentValue: { currency: "USD", value: "1.01" }, - }, - ]; - - const before = structuredClone(source); - const [group] = sections(source)[0].groups; - expect( - group.positions.map(item => [ - item.quantity, - item.currentValue, - item.averagePurchasePrice, - ]), - ).toEqual( - source.map(item => [ - item.quantity, - item.currentValue, - item.averagePurchasePrice, - ]), - ); - expect(source).toEqual(before); - }); }); diff --git a/src/pages/bond/ui/BondPage/BondPage.module.css b/src/pages/bond/ui/BondPage/BondPage.module.css index 51c9498..85bdea6 100644 --- a/src/pages/bond/ui/BondPage/BondPage.module.css +++ b/src/pages/bond/ui/BondPage/BondPage.module.css @@ -3,17 +3,6 @@ margin: 0 auto; min-width: 0; } -.section { - padding: var(--mantine-spacing-lg); - border-radius: var(--mantine-radius-lg); - min-width: 0; -} -.identity { - display: flex; - align-items: center; - gap: var(--mantine-spacing-md); - min-width: 0; -} .price { font-size: clamp(2rem, 4vw, 3.2rem); font-weight: 700; @@ -38,9 +27,6 @@ border-radius: var(--mantine-radius-md); } @media (max-width: 48em) { - .section { - padding: var(--mantine-spacing-md); - } .companyMetrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/src/pages/bond/ui/BondPage/BondPage.tsx b/src/pages/bond/ui/BondPage/BondPage.tsx index b7225d3..a870ea3 100644 --- a/src/pages/bond/ui/BondPage/BondPage.tsx +++ b/src/pages/bond/ui/BondPage/BondPage.tsx @@ -1,66 +1,23 @@ import type { FC } from "react"; -import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core"; -import { LoadingSkeleton } from "@wealthledger/ui"; -import { useTranslation } from "react-i18next"; -import { Link, Navigate } from "react-router"; +import { Navigate } from "react-router"; -import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; +import { InstrumentPageState } from "@/features/instrument-details"; import { BondPageView } from "./BondPage.view"; import { useBondPageVm } from "./BondPage.vm"; const BondPage: FC = () => { const vm = useBondPageVm(); - const { t } = useTranslation(); if (vm.canonicalTo) return ; - if (vm.bondState.loading) - return ( - - - - - - - - - - ); - - const unavailableMessage = isApiErrorCode( - vm.code, - ApiErrorCode.BondTickerAmbiguous, - ) - ? "bond.ambiguousTicker" - : "bond.unavailableText"; - - if (!vm.bond) - return ( - - {t("bond.unavailable")} - - - {t( - isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) - || isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken) - ? "bond.connect" - : unavailableMessage, - )} - - - - {t("bond.backToSearch")} - - - - {t("accounts.openSettings")} - - - - - ); + if (vm.bondState.loading || !vm.bond) return ( + + ); return ; }; diff --git a/src/pages/bond/ui/BondPage/BondPage.view.tsx b/src/pages/bond/ui/BondPage/BondPage.view.tsx index f3cbbbd..3aaa379 100644 --- a/src/pages/bond/ui/BondPage/BondPage.view.tsx +++ b/src/pages/bond/ui/BondPage/BondPage.view.tsx @@ -2,11 +2,8 @@ import type { FC } from "react"; import { Alert, - Avatar, Badge, - Button, Group, - Paper, Skeleton, Stack, Text, @@ -18,6 +15,7 @@ import { useTranslation } from "react-i18next"; import { InstrumentChart, InstrumentEvents, + InstrumentHeader, InstrumentPosition, } from "@/features/instrument-details"; import { @@ -42,6 +40,8 @@ const BondPageView: FC = (props) => { const bond = vm.bond; if (!bond) return; + const subtitle = `${bond.ticker ?? ""} · ${bond.isin ?? ""}`; + const maturity = bond.maturityDate ? formatDate(bond.maturityDate) : t("bond.unknown"); @@ -144,32 +144,13 @@ const BondPageView: FC = (props) => { return ( - - -
- - {bond.name[0]} - - -
- - {bond.name} - - - - {bond.ticker} · {bond.isin} - -
-
- - -
+ {!!bond.floatingCoupon && {t("bond.floatingCoupon")}} @@ -224,7 +205,7 @@ const BondPageView: FC = (props) => { -
+ diff --git a/src/pages/bond/ui/BondPage/BondPage.vm.ts b/src/pages/bond/ui/BondPage/BondPage.vm.ts index 46271c9..7279927 100644 --- a/src/pages/bond/ui/BondPage/BondPage.vm.ts +++ b/src/pages/bond/ui/BondPage/BondPage.vm.ts @@ -1,7 +1,6 @@ -import { useParams, useSearchParams } from "react-router"; +import { useParams } from "react-router"; import { - type BondPeriod, useBond, useBondCandles, useBondCoupons, @@ -9,37 +8,21 @@ import { useBondPosition, useBondQuote, } from "@/entities/bond"; -import { type OperationCategory, useBrokerageOperationHistory } from "@/entities/broker-operation"; -import { section } from "@/features/instrument-details"; +import { useBrokerageOperationHistory } from "@/entities/broker-operation"; +import { section, useInstrumentParameters } from "@/features/instrument-details"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; import { instrumentLogoUrl } from "@/shared/lib"; -const periods: BondPeriod[] = ["month", "halfYear", "year", "all"]; -const filters = ["all", "trades", "coupons", "other"] as const; - -type BondEventFilter = (typeof filters)[number]; - -const categories: Record = { - all: [], - coupons: ["income"], - other: ["tax", "fee", "transfer", "other"], - trades: ["trade"], -}; - type BondPageVm = ReturnType; function useBondPageVm() { const { bondId = "" } = useParams(); const bond = useBond(bondId); const uid = bond.data?.instrumentUid ?? ""; - const [parameters, setParameters] = useSearchParams(); - const period - = periods.find(value => value === parameters.get("period")) ?? "month"; - - const filter - = filters.find(value => value === parameters.get("events")) ?? "all"; + const { categories, filter, period, search, setFilter, setPeriod } + = useInstrumentParameters("coupons"); const quote = useBondQuote(uid); const metrics = useBondMetrics(uid); @@ -48,8 +31,7 @@ function useBondPageVm() { const coupons = useBondCoupons(uid); const history = useBrokerageOperationHistory(uid ? "all" : undefined, { - // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - categories: categories[filter], + categories, instrumentUid: uid, limit: 20, }); @@ -60,9 +42,7 @@ function useBondPageVm() { { accountId: "all", candlePeriod: period, - categories: - // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - categories[filter], + categories, instrumentUid: uid, limit: 20, screen: "bond", @@ -70,14 +50,6 @@ function useBondPageVm() { [bond, quote, candles, position, metrics, coupons, operations], ); - const setParameter = (key: string, value: string) => { - const next = new URLSearchParams(parameters); - next.set(key, value); - setParameters(next, { replace: true }); - }; - - const search = parameters.size > 0 ? `?${parameters.toString()}` : ""; - return { bond: bond.data, bondState: section(bond, uid ? sync.retry : () => bond.refetch()), @@ -132,8 +104,8 @@ function useBondPageVm() { refresh: sync.refresh, refreshError: sync.transportError || sync.sourceError, refreshing: sync.refreshing, - setFilter: (value: string) => setParameter("events", value), - setPeriod: (value: string) => setParameter("period", value), + setFilter, + setPeriod, upcoming: coupons.data?.coupons .filter(event => event.timing === "upcoming") diff --git a/src/pages/stock/ui/StockPage/StockPage.module.css b/src/pages/stock/ui/StockPage/StockPage.module.css index 0fffd90..31068a1 100644 --- a/src/pages/stock/ui/StockPage/StockPage.module.css +++ b/src/pages/stock/ui/StockPage/StockPage.module.css @@ -3,17 +3,6 @@ margin: 0 auto; min-width: 0; } -.section { - padding: var(--mantine-spacing-lg); - border-radius: var(--mantine-radius-lg); - min-width: 0; -} -.identity { - display: flex; - align-items: center; - gap: var(--mantine-spacing-md); - min-width: 0; -} .price { font-size: clamp(2rem, 4vw, 3.2rem); font-weight: 700; @@ -38,20 +27,12 @@ grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); gap: var(--mantine-spacing-lg); } -.embeddedSection { - margin-top: var(--mantine-spacing-lg); - padding-top: var(--mantine-spacing-lg); - border-top: 1px solid var(--wl-color-border); -} .companyMetrics > div { padding: var(--mantine-spacing-sm); background: var(--wl-color-subtle); border-radius: var(--mantine-radius-md); } @media (max-width: 48em) { - .section { - padding: var(--mantine-spacing-md); - } .companyMetrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/src/pages/stock/ui/StockPage/StockPage.tsx b/src/pages/stock/ui/StockPage/StockPage.tsx index 7301a24..a9780a7 100644 --- a/src/pages/stock/ui/StockPage/StockPage.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.tsx @@ -1,66 +1,23 @@ import type { FC } from "react"; -import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core"; -import { LoadingSkeleton } from "@wealthledger/ui"; -import { useTranslation } from "react-i18next"; -import { Link, Navigate } from "react-router"; +import { Navigate } from "react-router"; -import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; +import { InstrumentPageState } from "@/features/instrument-details"; import { StockPageView } from "./StockPage.view"; import { useStockPageVm } from "./StockPage.vm"; const StockPage: FC = () => { const vm = useStockPageVm(); - const { t } = useTranslation(); if (vm.canonicalTo) return ; - if (vm.stockState.loading) - return ( - - - - - - - - - - ); - - const unavailableMessage = isApiErrorCode( - vm.code, - ApiErrorCode.StockTickerAmbiguous, - ) - ? "stock.ambiguousTicker" - : "stock.unavailableText"; - - if (!vm.stock) - return ( - - {t("stock.unavailable")} - - - {t( - isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) - || isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken) - ? "stock.connect" - : unavailableMessage, - )} - - - - {t("stock.backToSearch")} - - - - {t("accounts.openSettings")} - - - - - ); + if (vm.stockState.loading || !vm.stock) return ( + + ); return ; }; diff --git a/src/pages/stock/ui/StockPage/StockPage.view.tsx b/src/pages/stock/ui/StockPage/StockPage.view.tsx index 71fa99d..728e569 100644 --- a/src/pages/stock/ui/StockPage/StockPage.view.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.view.tsx @@ -2,20 +2,18 @@ import type { FC } from "react"; import { Alert, - Avatar, Badge, Button, Group, - Paper, Skeleton, Stack, Text, - Title, } from "@mantine/core"; import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { + InstrumentHeader, InstrumentChart as StockChart, InstrumentEvents as StockEvents, InstrumentPosition as StockPosition, @@ -30,7 +28,7 @@ import { import type { StockPageVm } from "./StockPage.vm"; -import { StockDividends } from "./components/StockEvents.view"; +import { StockDividends } from "./components/StockDividends.view"; import classes from "./StockPage.module.css"; type StockPageViewProps = { vm: StockPageVm }; @@ -41,6 +39,15 @@ const StockPageView: FC = (props) => { const stock = vm.stock; if (!stock) return; + const subtitle = ( + <> + {stock.ticker} ·{" "} + {t(`stock.shareTypes.${stock.shareType}`, { + defaultValue: t("stock.share"), + })} + + ); + const appearanceClass = vm.quote?.change?.value.startsWith("-") ? classes.negative : classes.positive; @@ -152,41 +159,13 @@ const StockPageView: FC = (props) => { return ( - - -
- - {stock.name[0]} - - -
- - {stock.name} - - - - {stock.ticker} ·{" "} - {t(`stock.shareTypes.${stock.shareType}`, { - defaultValue: t("stock.share"), - })} - -
-
- - -
+ {!!vm.company?.description && ( @@ -246,13 +225,18 @@ const StockPageView: FC = (props) => {
)} - + - +
diff --git a/src/pages/stock/ui/StockPage/StockPage.vm.ts b/src/pages/stock/ui/StockPage/StockPage.vm.ts index 2e5d095..9e416ee 100644 --- a/src/pages/stock/ui/StockPage/StockPage.vm.ts +++ b/src/pages/stock/ui/StockPage/StockPage.vm.ts @@ -1,8 +1,7 @@ -import { useParams, useSearchParams } from "react-router"; +import { useParams } from "react-router"; -import { type OperationCategory, useBrokerageOperationHistory } from "@/entities/broker-operation"; +import { useBrokerageOperationHistory } from "@/entities/broker-operation"; import { - type StockPeriod, useStock, useStockCandles, useStockCompany, @@ -12,36 +11,21 @@ import { } from "@/entities/stock"; import { section, + useInstrumentParameters, } from "@/features/instrument-details"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; import { instrumentLogoUrl } from "@/shared/lib"; -const periods: StockPeriod[] = ["month", "halfYear", "year", "all"]; -const filters = ["all", "trades", "dividends", "other"] as const; - -type StockEventFilter = (typeof filters)[number]; - -const categories: Record = { - all: [], - dividends: ["income"], - other: ["tax", "fee", "transfer", "other"], - trades: ["trade"], -}; - type StockPageVm = ReturnType; function useStockPageVm() { const { stockId = "" } = useParams(); const stock = useStock(stockId); const uid = stock.data?.instrumentUid ?? ""; - const [parameters, setParameters] = useSearchParams(); - const period - = periods.find(value => value === parameters.get("period")) ?? "month"; - - const filter - = filters.find(value => value === parameters.get("events")) ?? "all"; + const { categories, filter, period, search, setFilter, setPeriod } + = useInstrumentParameters("dividends"); const quote = useStockQuote(uid); const candles = useStockCandles(uid, period); @@ -50,8 +34,7 @@ function useStockPageVm() { const dividends = useStockDividends(uid); const history = useBrokerageOperationHistory(uid ? "all" : undefined, { - // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - categories: categories[filter], + categories, instrumentUid: uid, limit: 20, }); @@ -62,9 +45,7 @@ function useStockPageVm() { { accountId: "all", candlePeriod: period, - categories: - // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - categories[filter], + categories, instrumentUid: uid, limit: 20, screen: "stock", @@ -72,15 +53,6 @@ function useStockPageVm() { [stock, quote, candles, position, company, dividends, operations], ); - const setParameter = (key: string, value: string) => { - const next = new URLSearchParams(parameters); - next.set(key, value); - setParameters(next, { replace: true }); - }; - - const companyData = company.data; - const search = parameters.size > 0 ? `?${parameters.toString()}` : ""; - return { candles: candles.data, candlesState: section(candles, sync.retry), @@ -89,7 +61,7 @@ function useStockPageVm() { ? `/stocks/${encodeURIComponent(stock.data.slug)}${search}` : null, code: apiErrorCode(stock.error), - company: companyData, + company: company.data, companyState: section(company, sync.retry), dividends: dividends.data, dividendsState: section(dividends, sync.retry), @@ -133,8 +105,8 @@ function useStockPageVm() { refresh: sync.refresh, refreshError: sync.transportError || sync.sourceError, refreshing: sync.refreshing, - setFilter: (value: string) => setParameter("events", value), - setPeriod: (value: string) => setParameter("period", value), + setFilter, + setPeriod, stock: stock.data, stockState: section(stock, uid ? sync.retry : () => stock.refetch()), upcoming: diff --git a/src/pages/stock/ui/StockPage/components/StockEvents.view.tsx b/src/pages/stock/ui/StockPage/components/StockDividends.view.tsx similarity index 80% rename from src/pages/stock/ui/StockPage/components/StockEvents.view.tsx rename to src/pages/stock/ui/StockPage/components/StockDividends.view.tsx index 780c6d1..ac54370 100644 --- a/src/pages/stock/ui/StockPage/components/StockEvents.view.tsx +++ b/src/pages/stock/ui/StockPage/components/StockDividends.view.tsx @@ -11,25 +11,30 @@ import { } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { InstrumentSection as StockSection } from "@/features/instrument-details"; -import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; +import type { StockDividends as Dividends } from "@/entities/stock"; -import type { StockPageVm } from "../StockPage.vm"; +import { InstrumentSection, type SectionState } from "@/features/instrument-details"; +import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; import classes from "../StockPage.module.css"; -type StockEventsProps = { vm: StockPageVm }; +type StockDividendsProps = { + dividends?: Dividends; + pastDividends: Dividends["dividends"]; + state: SectionState; + upcoming: Dividends["dividends"]; +}; -const StockDividends: FC = (props) => { - const { vm } = props; +const StockDividends: FC = (props) => { + const { dividends, pastDividends, state, upcoming } = props; const { t } = useTranslation(); let upcomingContent; - if (vm.dividends) { - if (vm.upcoming.length > 0) { + if (dividends) { + if (upcoming.length > 0) { upcomingContent = ( - vm.upcoming.map(event => ( + upcoming.map(event => (
= (props) => { else { upcomingContent = ( - {t("stock.noUpcoming", { to: formatDate(vm.dividends.to) })} + {t("stock.noUpcoming", { to: formatDate(dividends.to) })} ); } @@ -112,10 +117,10 @@ const StockDividends: FC = (props) => { let pastDividendsContent; - if (vm.pastDividends.length > 0) { + if (pastDividends.length > 0) { pastDividendsContent = ( - {vm.pastDividends.map(event => ( + {pastDividends.map(event => ( = (props) => { } return ( - - {!!vm.dividends && ( + + {!!dividends && ( {!!( - vm.dividends.estimateUnavailable || vm.dividends.positionIsStale + dividends.estimateUnavailable || dividends.positionIsStale ) && ( {t("stock.estimateUnavailable")} @@ -183,10 +188,10 @@ const StockDividends: FC = (props) => { {t("stock.dividendNote")} - {!!vm.dividends.positionAt && ( + {!!dividends.positionAt && ( {t("stock.snapshotAt", { - date: formatSnapshotTime(vm.dividends.positionAt), + date: formatSnapshotTime(dividends.positionAt), })} )} @@ -200,8 +205,8 @@ const StockDividends: FC = (props) => { {t("stock.dividendWindow", { - from: formatDate(vm.dividends.from), - to: formatDate(vm.dividends.to), + from: formatDate(dividends.from), + to: formatDate(dividends.to), })} @@ -211,7 +216,7 @@ const StockDividends: FC = (props) => { )} - + ); }; diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index 3851c8b..49b5425 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -14,6 +14,5 @@ export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; export { invalidateResourceAccess, type InvestmentResourceMeta, - unwrapResourceResult, useResourceQuery, } from "./resource-query"; diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts index 02be78c..653b6f6 100644 --- a/src/shared/api/resource-query.ts +++ b/src/shared/api/resource-query.ts @@ -188,6 +188,5 @@ function useResourceQuery( export { invalidateResourceAccess, type InvestmentResourceMeta, - unwrapResourceResult, useResourceQuery, }; -- 2.54.0 From 7c28a423dd8a3cc4a5bdcb49b8de7018979df413 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 29 Sep 2026 06:15:31 +0300 Subject: [PATCH 09/16] refactor: tighten types and reuse instrument UI --- .ai/conventions/ui.md | 4 +- dependency-cruiser.cjs | 9 +++ eslint.config.mjs | 3 + packages/icons/tsconfig.json | 5 +- .../ui/src/EntryCard/EntryCard.module.css | 10 +++ .../ui/src/EntryCard/EntryCard.stories.tsx | 2 + packages/ui/src/EntryCard/EntryCard.tsx | 8 ++- packages/ui/tsconfig.json | 5 +- scripts/test-eslint.mjs | 15 +++++ src/app/routes/AppRoutes/AppRoutes.test.tsx | 2 + .../RouteErrorBoundary/RouteErrorBoundary.tsx | 6 +- src/app/routes/RouteGuards.tsx | 7 ++- src/entities/bank-card/api/banking.test.tsx | 4 +- src/entities/bank-card/api/mutations.ts | 20 +++--- src/entities/bank-card/api/queries.ts | 3 +- .../ui/BankCardForm/BankCardForm.tsx | 7 +-- .../ui/BankCardForm/useOpeningDateField.ts | 2 +- .../bank-card/ui/BankForms.module.css | 6 -- .../ui/BankSummary/BankSummary.view.tsx | 2 + .../CategoryManager/CategoryManager.test.tsx | 9 +-- .../ui/CategoryManager/CategoryManager.tsx | 9 ++- .../brokerage-event/ui/BrokerageEventCard.tsx | 2 +- src/features/auth/model/auth.test.tsx | 4 +- .../auth/ui/AuthForm/AuthForm.module.css | 4 -- src/features/auth/ui/AuthForm/AuthForm.tsx | 21 +++---- src/features/instrument-details/index.ts | 3 + .../model/useInstrumentParameters.ts | 10 +-- .../ui/InstrumentDetails.module.css | 29 +++++++++ .../ui/InstrumentMetric.view.tsx} | 6 +- .../ui/InstrumentQuoteStatus.view.tsx | 33 ++++++++++ .../tbank-connection/model/screen-sync.ts | 2 +- .../AccountEventsPage.test.tsx | 4 +- src/pages/account-income/model/display.ts | 5 +- .../AccountIncomePage.test.tsx | 2 +- .../AccountOperationsPage.test.tsx | 8 ++- src/pages/account/model/positions.fixtures.ts | 6 +- src/pages/account/model/positions.test.ts | 22 +++---- src/pages/account/model/positions.ts | 2 +- .../ui/AccountPage/AccountPage.test.tsx | 6 +- .../ui/AccountPage/position-groups.test.tsx | 6 +- .../position-layout.browser.test.tsx | 3 +- .../bond/ui/BondPage/BondPage.module.css | 33 ---------- src/pages/bond/ui/BondPage/BondPage.view.tsx | 46 +++++--------- .../BondPage/components/BondCoupons.view.tsx | 8 +-- .../BondPage/components/BondMetrics.view.tsx | 37 +++++------ src/pages/bonds/ui/BondsPage/BondsPage.vm.ts | 2 +- .../card/ui/CardPage/CardPage.module.css | 13 ---- src/pages/card/ui/CardPage/CardPage.view.tsx | 5 +- .../ui/CardPage/CardPagePresentation.vm.ts | 2 +- .../ui/OperationForm/OperationFields.vm.ts | 2 +- .../ui/OperationForm/OperationForm.module.css | 6 -- .../ui/OperationForm/OperationForm.schema.ts | 2 +- .../ui/OperationForm/OperationForm.test.tsx | 2 +- .../ui/OperationForm/OperationForm.view.tsx | 9 ++- .../cards/ui/CardsPage/CardsPage.test.tsx | 10 +-- .../cards/ui/CardsPage/CardsPage.view.tsx | 2 + .../stock/ui/StockPage/StockPage.module.css | 32 +--------- .../stock/ui/StockPage/StockPage.view.tsx | 62 ++++++++----------- .../stocks/ui/StocksPage/StocksPage.vm.ts | 2 +- src/shared/api/authenticated-query.test.tsx | 6 +- src/shared/lib/calendar.ts | 14 +++-- src/shared/lib/display.ts | 36 ++++++----- src/shared/theme/Foundations.stories.tsx | 5 +- src/shared/theme/contrast.test.ts | 40 ++++++++---- tsconfig.app.json | 10 +-- tsconfig.base.json | 15 +++++ tsconfig.node.json | 10 +-- 67 files changed, 380 insertions(+), 347 deletions(-) delete mode 100644 src/entities/bank-card/ui/BankForms.module.css delete mode 100644 src/features/auth/ui/AuthForm/AuthForm.module.css rename src/{pages/bond/ui/BondPage/components/Metric.view.tsx => features/instrument-details/ui/InstrumentMetric.view.tsx} (57%) create mode 100644 src/features/instrument-details/ui/InstrumentQuoteStatus.view.tsx delete mode 100644 src/pages/bond/ui/BondPage/BondPage.module.css delete mode 100644 src/pages/card/ui/OperationForm/OperationForm.module.css create mode 100644 tsconfig.base.json diff --git a/.ai/conventions/ui.md b/.ai/conventions/ui.md index 69db119..432d6ec 100644 --- a/.ai/conventions/ui.md +++ b/.ai/conventions/ui.md @@ -110,8 +110,8 @@ Render-helper замени самостоятельным presentation component При переносе обновляй imports, ссылки документации и проверки, зависящие от пути. Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx) -лежит рядом со своим view, тестом и story; общий `BankForms.module.css` -остаётся в `bank-card/ui`. +лежит рядом со своим view, тестом и story. Формы используют Mantine +`Fieldset variant="unstyled"` для групповой блокировки полей без локального CSS. ## Формы diff --git a/dependency-cruiser.cjs b/dependency-cruiser.cjs index 777da44..39beab8 100644 --- a/dependency-cruiser.cjs +++ b/dependency-cruiser.cjs @@ -1,5 +1,14 @@ module.exports = { forbidden: [ + { + from: { + path: "^(src|packages/[^/]+/src)/", + pathNot: "[.](test|stories|fixtures)[.]|^src/test/", + }, + name: "production-must-not-depend-on-test-code", + severity: "error", + to: { path: "[.](test|stories|fixtures)[.]|^src/test/" }, + }, { from: {}, name: "no-unresolved", diff --git a/eslint.config.mjs b/eslint.config.mjs index 2885a5a..1222ea7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -153,11 +153,13 @@ export default defineConfig( }, }, rules: { + "@typescript-eslint/consistent-type-imports": "error", // React ignores event handler return values; floating promises are still checked in bodies. "@typescript-eslint/no-misused-promises": [ "error", { checksVoidReturn: { attributes: false } }, ], + "@typescript-eslint/no-non-null-assertion": "error", "@typescript-eslint/switch-exhaustiveness-check": "error", }, }, @@ -197,6 +199,7 @@ export default defineConfig( ignoreTypeIndexes: true, }, ], + "@typescript-eslint/no-unnecessary-condition": ["error", { allowConstantLoopConditions: true }], }, }, { diff --git a/packages/icons/tsconfig.json b/packages/icons/tsconfig.json index 77d9d3e..a937da9 100644 --- a/packages/icons/tsconfig.json +++ b/packages/icons/tsconfig.json @@ -1,15 +1,12 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { - "target": "ES2023", "lib": [ "ES2023", "DOM" ], "module": "ESNext", "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "verbatimModuleSyntax": true, "declaration": true, "emitDeclarationOnly": true, "rootDir": "src", diff --git a/packages/ui/src/EntryCard/EntryCard.module.css b/packages/ui/src/EntryCard/EntryCard.module.css index 2aca6de..fbdcf96 100644 --- a/packages/ui/src/EntryCard/EntryCard.module.css +++ b/packages/ui/src/EntryCard/EntryCard.module.css @@ -93,3 +93,13 @@ transition: none; } } + +.row[data-wrap] p { + white-space: normal; + overflow-wrap: anywhere; +} +@media (min-width: 36em) { + .row[data-wrap] { + grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%); + } +} diff --git a/packages/ui/src/EntryCard/EntryCard.stories.tsx b/packages/ui/src/EntryCard/EntryCard.stories.tsx index 3d06295..063520c 100644 --- a/packages/ui/src/EntryCard/EntryCard.stories.tsx +++ b/packages/ui/src/EntryCard/EntryCard.stories.tsx @@ -39,3 +39,5 @@ export default meta; type Story = StoryObj; export const LongContentAndFallback: Story = {}; + +export const WrappedOperation: Story = { args: { value: "−123 456 789 012 345 678,90 ₽", wrap: true } }; diff --git a/packages/ui/src/EntryCard/EntryCard.tsx b/packages/ui/src/EntryCard/EntryCard.tsx index 9f5d9c0..8a63f82 100644 --- a/packages/ui/src/EntryCard/EntryCard.tsx +++ b/packages/ui/src/EntryCard/EntryCard.tsx @@ -18,6 +18,7 @@ type EntryCardProps = { | "positive"; value: string; valueLabel: string; + wrap?: boolean; }; const EntryCard: FC = (props) => { @@ -31,7 +32,12 @@ const EntryCard: FC = (props) => { const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`; return ( - +