refactor: clarify frontend module ownership
This commit is contained in:
@@ -6,6 +6,8 @@ import { apiClient, configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
|
||||
import type { BankCard, CreateBankOperation } from "./types";
|
||||
|
||||
import {
|
||||
useCreateBankOperation,
|
||||
useDeleteBankOperation,
|
||||
@@ -14,8 +16,6 @@ import {
|
||||
useUpdateExpenseCategory,
|
||||
} from "./mutations";
|
||||
import {
|
||||
type BankCard,
|
||||
type CreateBankOperation,
|
||||
useBankCard,
|
||||
useBankCards,
|
||||
useBankCategories,
|
||||
@@ -106,14 +106,14 @@ function useReadModels() {
|
||||
const categories = useBankCategories();
|
||||
|
||||
const history = useBankOperations("a", {
|
||||
limit: 20,
|
||||
month: "2026-09",
|
||||
page: 2,
|
||||
from: "2026-09-01",
|
||||
limit: 20, page: 2,
|
||||
to: "2026-09-30",
|
||||
});
|
||||
|
||||
const expenses = useBankExpenses("a", { page: 1 });
|
||||
const september = useBankSummary("2026-09");
|
||||
const august = useBankSummary("2026-08", "b");
|
||||
const september = useBankSummary({ from: "2026-09-01", to: "2026-09-30" });
|
||||
const august = useBankSummary({ from: "2026-08-01", to: "2026-08-31" }, "b");
|
||||
const original = useBankOperation("original");
|
||||
|
||||
return {
|
||||
@@ -144,7 +144,8 @@ describe("banking API", () => {
|
||||
const page = Number(url.searchParams.get("page"));
|
||||
pages.push(page);
|
||||
expect(url.searchParams.get("limit")).toBe("100");
|
||||
expect(url.searchParams.get("month")).toBe("2026-09");
|
||||
expect(url.searchParams.get("from")).toBe("2026-09-01");
|
||||
expect(url.searchParams.get("to")).toBe("2026-09-30");
|
||||
if (isFail && page === 2)
|
||||
return HttpResponse.json({ code: "FAILED" }, { status: 500 });
|
||||
|
||||
@@ -158,7 +159,7 @@ describe("banking API", () => {
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
|
||||
() => useBankOperations("a", { from: "2026-09-01", limit: "all", page: 1, to: "2026-09-30" }),
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
|
||||
@@ -171,7 +172,7 @@ describe("banking API", () => {
|
||||
isFail = true;
|
||||
|
||||
const { result: failedResult } = renderHook(
|
||||
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
|
||||
() => useBankOperations("a", { from: "2026-09-01", limit: "all", page: 1, to: "2026-09-30" }),
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
|
||||
@@ -179,7 +180,7 @@ describe("banking API", () => {
|
||||
expect(failedResult.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => {
|
||||
it("uses session-aware banking endpoints and explicit period/page without T-Bank", async () => {
|
||||
const counts = new Map<string, number>();
|
||||
server.use(...readHandlers(counts));
|
||||
|
||||
@@ -191,12 +192,15 @@ describe("banking API", () => {
|
||||
expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
|
||||
);
|
||||
expect(counts.keys()).toContain("/api/v1/bank-cards?includeArchived=true");
|
||||
expect(counts.keys()).toContain(
|
||||
"/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2",
|
||||
);
|
||||
expect(counts.keys()).toContain(
|
||||
"/api/v1/bank-cards/summary?cardId=b&month=2026-08",
|
||||
);
|
||||
const urls = Array.from(counts.keys(), path => new URL(path, "http://localhost"));
|
||||
const history = urls.find(url => url.pathname === "/api/v1/bank-cards/a/operations");
|
||||
const summary = urls.find(url => url.pathname === "/api/v1/bank-cards/summary" && url.searchParams.get("cardId") === "b");
|
||||
expect(Object.fromEntries(history?.searchParams ?? [])).toEqual({
|
||||
from: "2026-09-01", limit: "20", page: "2", to: "2026-09-30",
|
||||
});
|
||||
expect(Object.fromEntries(summary?.searchParams ?? [])).toEqual({
|
||||
cardId: "b", from: "2026-08-01", to: "2026-08-31",
|
||||
});
|
||||
expect(counts.size).toBe(9);
|
||||
});
|
||||
|
||||
@@ -303,7 +307,7 @@ describe("banking API", () => {
|
||||
expect(result.current.query.data?.balance.value).toBe("100");
|
||||
});
|
||||
|
||||
it("refetches both cards, every month, categories, journal and source expenses after a transfer without invalidating brokerage", async () => {
|
||||
it("refetches both cards, every period, categories, journal and source expenses after a transfer without invalidating brokerage", async () => {
|
||||
const counts = new Map<string, number>();
|
||||
server.use(
|
||||
...readHandlers(counts),
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
UpdateBankCard,
|
||||
UpdateBankOperation,
|
||||
UpdateExpenseCategory,
|
||||
} from "./queries";
|
||||
} from "./types";
|
||||
|
||||
async function mutationData<T>(
|
||||
request: Promise<{ data?: T; error?: unknown }>,
|
||||
|
||||
@@ -1,44 +1,12 @@
|
||||
import {
|
||||
apiClient,
|
||||
apiQueryClient,
|
||||
type paths,
|
||||
useAuthenticatedQuery,
|
||||
} from "@/shared/api";
|
||||
|
||||
import { moneyCents } from "../model/form-validation";
|
||||
import type { BankOperation, BankOperations, BankSummary } from "./types";
|
||||
|
||||
type BankCard
|
||||
= paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
type BankOperation
|
||||
= paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
type BankOperations
|
||||
= paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
type BankSummary
|
||||
= paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
type CreateBankCard
|
||||
= paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"];
|
||||
|
||||
type CreateBankOperation
|
||||
= paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"];
|
||||
|
||||
type CreateExpenseCategory
|
||||
= paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"];
|
||||
|
||||
type ExpenseCategory
|
||||
= paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number];
|
||||
|
||||
type UpdateBankCard
|
||||
= paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"];
|
||||
|
||||
type UpdateBankOperation
|
||||
= paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"];
|
||||
|
||||
type UpdateExpenseCategory
|
||||
= paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"];
|
||||
import { moneyCents } from "../model/money";
|
||||
|
||||
const OPERATIONS_PAGE_SIZE = 100;
|
||||
|
||||
@@ -110,7 +78,6 @@ function useBankOperations(
|
||||
query: {
|
||||
from?: string;
|
||||
limit?: "all" | number;
|
||||
month?: string;
|
||||
page: number;
|
||||
to?: string;
|
||||
},
|
||||
@@ -134,7 +101,7 @@ function useBankOperations(
|
||||
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity.
|
||||
return useAuthenticatedQuery({
|
||||
enabled: Boolean(id && (query.month || (query.from && query.to))),
|
||||
enabled: Boolean(id && query.from && query.to),
|
||||
placeholderData: (
|
||||
previousData: BankOperations | undefined,
|
||||
previousQuery,
|
||||
@@ -144,7 +111,6 @@ function useBankOperations(
|
||||
return !isAll
|
||||
&& previousQuery?.queryKey.length === options.queryKey.length
|
||||
&& previous?.path.id === id
|
||||
&& previous?.query?.month === query.month
|
||||
&& previous?.query?.from === query.from
|
||||
&& previous?.query?.to === query.to
|
||||
&& previous?.query?.limit === query.limit
|
||||
@@ -179,36 +145,19 @@ function useBankOperations(
|
||||
}
|
||||
|
||||
function useBankSummary(
|
||||
period: string
|
||||
| { from: string; to: string },
|
||||
period: { from: string; to: string },
|
||||
cardId?: string,
|
||||
) {
|
||||
const query
|
||||
= typeof period === "string"
|
||||
? { cardId, month: period }
|
||||
: { ...period, cardId };
|
||||
|
||||
return useAuthenticatedQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", {
|
||||
params: { query },
|
||||
params: { query: { ...period, cardId } },
|
||||
}),
|
||||
enabled: Boolean(period),
|
||||
select: sortBankSummary,
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
type BankCard,
|
||||
type BankOperation,
|
||||
type BankSummary,
|
||||
type CreateBankCard,
|
||||
type CreateBankOperation,
|
||||
type CreateExpenseCategory,
|
||||
type ExpenseCategory,
|
||||
sortBankSummary,
|
||||
type UpdateBankCard,
|
||||
type UpdateBankOperation,
|
||||
type UpdateExpenseCategory,
|
||||
useBankCard,
|
||||
useBankCards,
|
||||
useBankCategories,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { type BankSummary, sortBankSummary } from "./queries";
|
||||
import type { BankSummary } from "./types";
|
||||
|
||||
import { sortBankSummary } from "./queries";
|
||||
|
||||
const money = (value: string) => ({ currency: "RUB", value });
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { paths } from "@/shared/api";
|
||||
|
||||
type BankCard
|
||||
= paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
type BankOperation
|
||||
= paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
type BankOperations
|
||||
= paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
type BankSummary
|
||||
= paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
type CreateBankCard
|
||||
= paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"];
|
||||
|
||||
type CreateBankOperation
|
||||
= paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"];
|
||||
|
||||
type CreateExpenseCategory
|
||||
= paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"];
|
||||
|
||||
type ExpenseCategory
|
||||
= paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number];
|
||||
|
||||
type UpdateBankCard
|
||||
= paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"];
|
||||
|
||||
type UpdateBankOperation
|
||||
= paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"];
|
||||
|
||||
type UpdateExpenseCategory
|
||||
= paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"];
|
||||
|
||||
export type {
|
||||
BankCard,
|
||||
BankOperation,
|
||||
BankOperations,
|
||||
BankSummary,
|
||||
CreateBankCard,
|
||||
CreateBankOperation,
|
||||
CreateExpenseCategory,
|
||||
ExpenseCategory,
|
||||
UpdateBankCard,
|
||||
UpdateBankOperation,
|
||||
UpdateExpenseCategory,
|
||||
};
|
||||
@@ -5,8 +5,6 @@ export {
|
||||
useUpdateBankOperation,
|
||||
} from "./api/mutations";
|
||||
export {
|
||||
type BankCard,
|
||||
type BankOperation,
|
||||
useBankCard,
|
||||
useBankCards,
|
||||
useBankCategories,
|
||||
@@ -15,14 +13,16 @@ export {
|
||||
useBankOperations,
|
||||
useBankSummary,
|
||||
} from "./api/queries";
|
||||
export { bankDateInputValue, bankMoneyValue, bankToday, moneyCents } from "./model/form-validation";
|
||||
export type { BankCard, BankOperation } from "./api/types";
|
||||
export { bankToday } from "./model/form-validation";
|
||||
export { bankDateSchema, positiveMoneySchema } from "./model/form.schema";
|
||||
|
||||
export { bankMoneyValue, moneyCents } from "./model/money";
|
||||
export { bankOperationAppearance } from "./model/operation-appearance";
|
||||
export { bankOperationKinds } from "./model/operation-kinds";
|
||||
export { bankPeriod } from "./model/period";
|
||||
export { BankCardFace } from "./ui/BankCardFace/BankCardFace";
|
||||
export { BankCardForm } from "./ui/BankCardForm/BankCardForm";
|
||||
export { bankDateField } from "./ui/bankDateField";
|
||||
export { BankFeedback } from "./ui/BankFeedback";
|
||||
export { BankPeriodPicker } from "./ui/BankPeriodPicker/BankPeriodPicker";
|
||||
export { BankSummaryView } from "./ui/BankSummary/BankSummary.view";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
|
||||
import { validationMessage } from "@/shared/i18n";
|
||||
|
||||
import type { ExpenseCategory } from "../api/queries";
|
||||
import type { ExpenseCategory } from "../api/types";
|
||||
const MAX_CATEGORY_NAME_LENGTH = 80;
|
||||
|
||||
function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { bankDateInputValue, bankMoneyValue, bankMonth, bankToday } from "./form-validation";
|
||||
import { bankCardSchema, bankDateSchema, positiveMoneySchema } from "./form.schema";
|
||||
import { bankDateInputValue, bankMonth, bankToday } from "./form-validation";
|
||||
import {
|
||||
bankCardSchema,
|
||||
bankDateSchema,
|
||||
positiveMoneySchema,
|
||||
} from "./form.schema";
|
||||
import { bankMoneyValue } from "./money";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
describe("bank form input", () => {
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import { moscowDate } from "@/shared/lib";
|
||||
|
||||
const DECIMAL_PART_COUNT = 2;
|
||||
const CENTS_PRECISION = 2;
|
||||
|
||||
const ISO_MONTH_LENGTH = 7;
|
||||
|
||||
function bankDateInputValue(value: string) {
|
||||
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
|
||||
}
|
||||
|
||||
function bankMoneyValue(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.replace(",", ".")
|
||||
.replace(/^(-?)0+(?=\d)/, "$1");
|
||||
}
|
||||
|
||||
function bankMonth(value: null | string) {
|
||||
return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value)
|
||||
? value
|
||||
@@ -26,19 +16,4 @@ function bankToday() {
|
||||
return moscowDate(new Date());
|
||||
}
|
||||
|
||||
function moneyCents(value: string): bigint | null {
|
||||
const normalized = value.trim().replace(",", ".");
|
||||
if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized))
|
||||
return null;
|
||||
|
||||
const [whole, fraction = ""] = normalized
|
||||
.replace("-", "")
|
||||
.split(".", DECIMAL_PART_COUNT);
|
||||
|
||||
return (
|
||||
BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`)
|
||||
* (normalized.startsWith("-") ? -1n : 1n)
|
||||
);
|
||||
}
|
||||
|
||||
export { bankDateInputValue, bankMoneyValue, bankMonth, bankToday, moneyCents };
|
||||
export { bankDateInputValue, bankMonth, bankToday };
|
||||
|
||||
@@ -3,7 +3,8 @@ import { z } from "zod";
|
||||
import { validationMessage } from "@/shared/i18n";
|
||||
import { calendarDate } from "@/shared/lib";
|
||||
|
||||
import { bankToday, moneyCents } from "./form-validation";
|
||||
import { bankToday } from "./form-validation";
|
||||
import { moneyCents } from "./money";
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const DECIMAL_PART_COUNT = 2;
|
||||
const CENTS_PRECISION = 2;
|
||||
|
||||
function bankMoneyValue(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.replace(",", ".")
|
||||
.replace(/^(-?)0+(?=\d)/, "$1");
|
||||
}
|
||||
|
||||
function moneyCents(value: string): bigint | null {
|
||||
const normalized = value.trim().replace(",", ".");
|
||||
if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized))
|
||||
return null;
|
||||
|
||||
const [whole, fraction = ""] = normalized
|
||||
.replace("-", "")
|
||||
.split(".", DECIMAL_PART_COUNT);
|
||||
|
||||
return (
|
||||
BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`)
|
||||
* (normalized.startsWith("-") ? -1n : 1n)
|
||||
);
|
||||
}
|
||||
|
||||
export { bankMoneyValue, moneyCents };
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OperationIconProps } from "@wealthledger/icons";
|
||||
|
||||
import type { BankOperation } from "../api/queries";
|
||||
import type { BankOperation } from "../api/types";
|
||||
|
||||
const categoryIcons: {
|
||||
appearance: OperationIconProps;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BankOperation } from "../api/queries";
|
||||
import type { BankOperation } from "../api/types";
|
||||
|
||||
const bankOperationKinds = [
|
||||
"expense",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
import type { BankCard } from "../../api/queries";
|
||||
import type { BankCard } from "../../api/types";
|
||||
|
||||
import classes from "./BankCardFace.module.css";
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { useController, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BankCardValues } from "../../model/form.schema";
|
||||
|
||||
import { useOpeningDateField } from "./useOpeningDateField";
|
||||
import { usePaymentSystemField } from "./usePaymentSystemField";
|
||||
import { bankDateField } from "../bankDateField";
|
||||
|
||||
function useBankCardFieldsVm() {
|
||||
const {
|
||||
@@ -14,10 +13,26 @@ function useBankCardFieldsVm() {
|
||||
} = useFormContext<BankCardValues>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const openingDate = useOpeningDateField(control);
|
||||
const paymentSystem = usePaymentSystemField(control);
|
||||
const { field: openingDateField } = useController({ control, name: "openingDate" });
|
||||
const { field: paymentSystemField } = useController({ control, name: "paymentSystem" });
|
||||
|
||||
return { ...openingDate, ...paymentSystem, errors, register, t };
|
||||
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
|
||||
label: t(`bank.paymentSystems.${value}`),
|
||||
value,
|
||||
}));
|
||||
|
||||
const onPaymentSystemChange = (value: null | string) =>
|
||||
paymentSystemField.onChange(value ?? "");
|
||||
|
||||
return {
|
||||
errors,
|
||||
onPaymentSystemChange,
|
||||
openingDate: { ...openingDateField, ...bankDateField(openingDateField) },
|
||||
paymentSystemField,
|
||||
paymentSystems,
|
||||
register,
|
||||
t,
|
||||
};
|
||||
}
|
||||
|
||||
export { useBankCardFieldsVm };
|
||||
|
||||
@@ -8,7 +8,7 @@ import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
|
||||
import type { BankCard } from "../../api/queries";
|
||||
import type { BankCard } from "../../api/types";
|
||||
|
||||
import { BankCardForm } from "./BankCardForm";
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { FormProvider } from "react-hook-form";
|
||||
|
||||
import { formatDate } from "@/shared/lib";
|
||||
|
||||
import { type BankCard } from "../../api/queries";
|
||||
import type { BankCard } from "../../api/types";
|
||||
|
||||
import { BankFeedback } from "../BankFeedback";
|
||||
import { BankCardFields } from "./BankCardForm.view";
|
||||
import { useBankCardFormVm } from "./BankCardForm.vm";
|
||||
|
||||
@@ -10,14 +10,9 @@ type BankCardFieldsProps = { archived: boolean };
|
||||
|
||||
const BankCardFields: FC<BankCardFieldsProps> = (props) => {
|
||||
const {
|
||||
dateParser,
|
||||
errors,
|
||||
onOpeningDateBlur,
|
||||
onOpeningDateChange,
|
||||
onOpeningDateInput,
|
||||
onPaymentSystemChange,
|
||||
openingDateField,
|
||||
openingDateValue,
|
||||
openingDate,
|
||||
paymentSystemField,
|
||||
paymentSystems,
|
||||
register,
|
||||
@@ -71,22 +66,17 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
{...openingDateField}
|
||||
dateParser={dateParser}
|
||||
{...openingDate}
|
||||
error={errors.openingDate?.message}
|
||||
fixOnBlur={false}
|
||||
label={t("bank.openingDate")}
|
||||
maxDate={bankToday()}
|
||||
onBlur={onOpeningDateBlur}
|
||||
onChange={onOpeningDateChange}
|
||||
onInput={onOpeningDateInput}
|
||||
popoverProps={{
|
||||
middlewares: { shift: { crossAxis: true } },
|
||||
withinPortal: false,
|
||||
}}
|
||||
readOnly={props.archived}
|
||||
required
|
||||
value={openingDateValue}
|
||||
valueFormat="DD.MM.YYYY"
|
||||
/>
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BankCard } from "../../api/types";
|
||||
|
||||
import { useCreateBankCard, useUpdateBankCard } from "../../api/mutations";
|
||||
import { type BankCard, useBankCard } from "../../api/queries";
|
||||
import { bankMoneyValue, bankToday } from "../../model/form-validation";
|
||||
import { useBankCard } from "../../api/queries";
|
||||
import { bankToday } from "../../model/form-validation";
|
||||
import { bankCardSchema, type BankCardValues } from "../../model/form.schema";
|
||||
import { bankMoneyValue } from "../../model/money";
|
||||
import { useBankFormFocus } from "../useBankFormFocus";
|
||||
|
||||
type BankCardFormProps = { card?: BankCard; onClose: () => void };
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { DateInput } from "@mantine/dates";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { Control } from "react-hook-form";
|
||||
|
||||
import { useController } from "react-hook-form";
|
||||
|
||||
import { bankDateInputValue } from "../../model/form-validation";
|
||||
import { type BankCardValues, bankDateSchema } from "../../model/form.schema";
|
||||
|
||||
function useOpeningDateField(control: Control<BankCardValues>) {
|
||||
const { field: openingDateField } = useController({
|
||||
control,
|
||||
name: "openingDate",
|
||||
});
|
||||
|
||||
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = value =>
|
||||
bankDateSchema.safeParse(bankDateInputValue(value)).success
|
||||
? bankDateInputValue(value)
|
||||
: null;
|
||||
|
||||
const onOpeningDateBlur: ComponentProps<typeof DateInput>["onBlur"] = (
|
||||
event,
|
||||
) => {
|
||||
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
|
||||
openingDateField.onBlur();
|
||||
};
|
||||
|
||||
const onOpeningDateChange = (value: null | string) =>
|
||||
openingDateField.onChange(value ?? "");
|
||||
|
||||
const onOpeningDateInput: ComponentProps<typeof DateInput>["onInput"] = event =>
|
||||
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
|
||||
|
||||
const openingDateValue = bankDateSchema.safeParse(openingDateField.value)
|
||||
.success
|
||||
? openingDateField.value
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
dateParser,
|
||||
onOpeningDateBlur,
|
||||
onOpeningDateChange,
|
||||
onOpeningDateInput,
|
||||
openingDateField,
|
||||
openingDateValue,
|
||||
};
|
||||
}
|
||||
|
||||
export { useOpeningDateField };
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { Control } from "react-hook-form";
|
||||
|
||||
import { useController } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BankCardValues } from "../../model/form.schema";
|
||||
|
||||
function usePaymentSystemField(control: Control<BankCardValues>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { field: paymentSystemField } = useController({
|
||||
control,
|
||||
name: "paymentSystem",
|
||||
});
|
||||
|
||||
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
|
||||
label: t(`bank.paymentSystems.${value}`),
|
||||
value,
|
||||
}));
|
||||
|
||||
const onPaymentSystemChange = (value: null | string) =>
|
||||
paymentSystemField.onChange(value ?? "");
|
||||
|
||||
return { onPaymentSystemChange, paymentSystemField, paymentSystems };
|
||||
}
|
||||
|
||||
export { usePaymentSystemField };
|
||||
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
import type { BankSummary } from "../../api/queries";
|
||||
import type { BankSummary } from "../../api/types";
|
||||
|
||||
import { bankOperationAppearance } from "../../model/operation-appearance";
|
||||
import classes from "./BankSummary.module.css";
|
||||
|
||||
@@ -5,11 +5,13 @@ import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { ExpenseCategory } from "../../api/types";
|
||||
|
||||
import {
|
||||
useCreateExpenseCategory,
|
||||
useUpdateExpenseCategory,
|
||||
} from "../../api/mutations";
|
||||
import { type ExpenseCategory, useBankCategories } from "../../api/queries";
|
||||
import { useBankCategories } from "../../api/queries";
|
||||
import { categorySchema } from "../../model/category.schema";
|
||||
import { useBankFormFocus } from "../useBankFormFocus";
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { DateInput } from "@mantine/dates";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { bankDateInputValue } from "../model/form-validation";
|
||||
import { bankDateSchema } from "../model/form.schema";
|
||||
|
||||
type DateField = {
|
||||
onBlur: () => void;
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function bankDateField(field: DateField) {
|
||||
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = (value) => {
|
||||
const parsed = bankDateInputValue(value);
|
||||
|
||||
return bankDateSchema.safeParse(parsed).success ? parsed : null;
|
||||
};
|
||||
|
||||
const onBlur: ComponentProps<typeof DateInput>["onBlur"] = (event) => {
|
||||
field.onChange(bankDateInputValue(event.currentTarget.value));
|
||||
field.onBlur();
|
||||
};
|
||||
|
||||
const onInput: ComponentProps<typeof DateInput>["onInput"] = event =>
|
||||
field.onChange(bankDateInputValue(event.currentTarget.value));
|
||||
|
||||
return {
|
||||
dateParser,
|
||||
onBlur,
|
||||
onChange: (value: null | string) => field.onChange(value ?? ""),
|
||||
onInput,
|
||||
value: bankDateSchema.safeParse(field.value).success
|
||||
? field.value
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export { bankDateField };
|
||||
@@ -1,5 +1,9 @@
|
||||
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
|
||||
|
||||
type BondCandles = NonNullable<paths["/api/v1/bonds/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||
|
||||
type BondPosition = NonNullable<paths["/api/v1/bonds/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||
|
||||
const MIN_BOND_SEARCH_LENGTH = 2;
|
||||
|
||||
type BondCoupons = NonNullable<paths["/api/v1/bonds/{uid}/coupons"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||
@@ -101,8 +105,10 @@ function useBondSearch(query: string) {
|
||||
}
|
||||
|
||||
export {
|
||||
type BondCandles,
|
||||
type BondCoupons,
|
||||
type BondMetrics,
|
||||
type BondPosition,
|
||||
MIN_BOND_SEARCH_LENGTH,
|
||||
useBond,
|
||||
useBondCandles,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export {
|
||||
type BondCandles,
|
||||
type BondCoupons,
|
||||
type BondMetrics,
|
||||
type BondPosition,
|
||||
MIN_BOND_SEARCH_LENGTH,
|
||||
useBond,
|
||||
useBondCandles,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { operationLabelKey, paymentPresentation } from "../model/display";
|
||||
type LedgerEntryRowProps = {
|
||||
operation: BrokerageOperation;
|
||||
withDetails?: boolean;
|
||||
wrap?: boolean;
|
||||
};
|
||||
|
||||
const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
|
||||
@@ -84,6 +85,7 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
|
||||
tone={payment.tone}
|
||||
value={payment.amount}
|
||||
valueLabel={t(payment.label)}
|
||||
wrap={props.wrap}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ export {
|
||||
type BrokerageEvent,
|
||||
useBrokerageEvents,
|
||||
} from "./api/useBrokerageEvents";
|
||||
export { brokerageEventKey } from "./model/event-key";
|
||||
export { BrokerageEventCard } from "./ui/BrokerageEventCard";
|
||||
export {
|
||||
BrokerageEventsPreview,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { BrokerageEvent } from "../api/useBrokerageEvents";
|
||||
|
||||
function brokerageEventKey(event: BrokerageEvent) {
|
||||
return JSON.stringify([
|
||||
event.account?.id, event.type, event.date, event.instrumentUid,
|
||||
event.figi, event.ticker, event.logoName,
|
||||
]);
|
||||
}
|
||||
|
||||
export { brokerageEventKey };
|
||||
@@ -9,6 +9,7 @@ import { formatSnapshotTime } from "@/shared/lib";
|
||||
|
||||
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
|
||||
|
||||
import { brokerageEventKey } from "../../model/event-key";
|
||||
import { BrokerageEventCard } from "../BrokerageEventCard";
|
||||
|
||||
type BrokerageEventsPreviewProps = {
|
||||
@@ -66,15 +67,7 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
||||
&& events.items.map(event => (
|
||||
<BrokerageEventCard
|
||||
event={event}
|
||||
key={JSON.stringify([
|
||||
event.account?.id,
|
||||
event.type,
|
||||
event.date,
|
||||
event.instrumentUid,
|
||||
event.figi,
|
||||
event.ticker,
|
||||
event.logoName,
|
||||
])}
|
||||
key={brokerageEventKey(event)}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { apiClient, configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
|
||||
import {
|
||||
type Portfolio,
|
||||
portfolioQueryOptions,
|
||||
usePortfolio,
|
||||
} from "./usePortfolio";
|
||||
|
||||
const snapshot: Portfolio = {
|
||||
cashBalances: [],
|
||||
isStale: false,
|
||||
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
||||
positions: [],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
totalsByCurrency: [{ currency: "RUB", value: "123.000000001" }],
|
||||
};
|
||||
|
||||
function auth() {
|
||||
configureAuthClient({
|
||||
accessToken: () => "synthetic",
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(true),
|
||||
});
|
||||
}
|
||||
|
||||
describe("persistent resource query lifecycle", () => {
|
||||
it("observes into the canonical cache and rejects an old notified state before accepting the current one", async () => {
|
||||
auth();
|
||||
const modes: (null | string)[] = [];
|
||||
let observations = 0;
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/portfolio",
|
||||
({ request }) => {
|
||||
const mode = new URL(request.url).searchParams.get("sync");
|
||||
modes.push(mode);
|
||||
if (mode) observations += 1;
|
||||
|
||||
return HttpResponse.json(
|
||||
resource(snapshot, {
|
||||
stateVersion: observations > 1 ? "new" : "old",
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
const { queryClient, wrapper } = createQueryWrapper();
|
||||
const { result } = renderHook(() => usePortfolio("a"), { wrapper });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
await act(() => result.current.observe("new"));
|
||||
await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new"));
|
||||
|
||||
expect(modes).toEqual([null, "observe", "observe"]);
|
||||
expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
|
||||
expect(
|
||||
queryClient.getQueryData(portfolioQueryOptions("a").queryKey),
|
||||
).toMatchObject({ data: snapshot, meta: { stateVersion: "new" } });
|
||||
});
|
||||
it("does not convert missing or failed source data to empty financial success", async () => {
|
||||
auth();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(
|
||||
resource(null, {
|
||||
sync: {
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
id: "work",
|
||||
state: "failed",
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => usePortfolio("a"), {
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed"));
|
||||
expect(result.current.data).toBeUndefined();
|
||||
expect(result.current.isSuccess).toBe(false);
|
||||
expect(result.current.status).toBe("pending");
|
||||
expect(result.current.isPending).toBe(true);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
|
||||
expect(result.current.transport).toEqual(
|
||||
{
|
||||
error: null,
|
||||
fetchStatus: "idle",
|
||||
status: "success",
|
||||
},
|
||||
);
|
||||
expect(result.current.meta?.availability).toBe("missing");
|
||||
});
|
||||
it("keeps the refetch envelope while exposing the refreshed resource payload", async () => {
|
||||
auth();
|
||||
let isReady = false;
|
||||
const refreshed = resource(snapshot, { stateVersion: "ready" });
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(isReady ? refreshed : resource(null)),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => usePortfolio("a"), {
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.transport.status).toBe("success"),
|
||||
);
|
||||
await act(async () => {
|
||||
const missing = await result.current.refetch();
|
||||
expect(missing.status).toBe("success");
|
||||
expect(missing.data).toEqual(resource(null));
|
||||
expect(missing.data?.data).toBeNull();
|
||||
isReady = true;
|
||||
const loaded = await result.current.refetch();
|
||||
expect(loaded.data).toEqual(refreshed);
|
||||
expect(loaded.data?.data?.totalsByCurrency).toEqual(
|
||||
snapshot.totalsByCurrency,
|
||||
);
|
||||
});
|
||||
await waitFor(() => expect(result.current.data).toEqual(snapshot));
|
||||
expect(result.current.meta).toEqual(refreshed.meta);
|
||||
expect(result.current.status).toBe("success");
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
it("rejects a late ordinary response when an observe response for that resource arrived first", async () => {
|
||||
auth();
|
||||
|
||||
const { promise: blocked, resolve: release }
|
||||
= Promise.withResolvers<void>();
|
||||
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/portfolio",
|
||||
async ({ request }) => {
|
||||
if (!new URL(request.url).searchParams.has("sync")) await blocked;
|
||||
|
||||
return HttpResponse.json(resource(snapshot));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const old = apiClient.GET(
|
||||
"/api/v1/brokerage-accounts/{accountId}/portfolio",
|
||||
{ params: { path: { accountId: "a" } } },
|
||||
);
|
||||
|
||||
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
|
||||
const rejected = expect(old).rejects.toMatchObject({ name: "AbortError" });
|
||||
await apiClient.GET("/api/v1/brokerage-accounts/{accountId}/portfolio", {
|
||||
params: { path: { accountId: "a" }, query: { sync: "observe" } },
|
||||
});
|
||||
release();
|
||||
await rejected;
|
||||
});
|
||||
it("clears private snapshots on confirmed access loss and fences another pending success", async () => {
|
||||
auth();
|
||||
let isDenied = false;
|
||||
|
||||
const { promise: blocked, resolve: release }
|
||||
= Promise.withResolvers<void>();
|
||||
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/portfolio",
|
||||
async ({ params }) => {
|
||||
if (params.accountId === "b") await blocked;
|
||||
|
||||
return isDenied && params.accountId === "a"
|
||||
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
|
||||
: HttpResponse.json(resource(snapshot));
|
||||
},
|
||||
),
|
||||
);
|
||||
const { wrapper } = createQueryWrapper();
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({ a: usePortfolio("a"), b: usePortfolio("b") }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.a.data).toEqual(snapshot));
|
||||
isDenied = true;
|
||||
await act(() => result.current.a.refetch());
|
||||
await waitFor(() => expect(result.current.a.data).toBeUndefined());
|
||||
await act(async () => {
|
||||
release();
|
||||
await blocked;
|
||||
});
|
||||
expect(result.current.b.data).toBeUndefined();
|
||||
expect(result.current.a.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
useResourceQuery,
|
||||
} from "@/shared/api";
|
||||
|
||||
type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||
|
||||
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||
|
||||
const MIN_STOCK_SEARCH_LENGTH = 2;
|
||||
|
||||
type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||
@@ -104,7 +108,9 @@ function useStockSearch(query: string) {
|
||||
|
||||
export {
|
||||
MIN_STOCK_SEARCH_LENGTH,
|
||||
type StockCandles,
|
||||
type StockDividends,
|
||||
type StockPosition,
|
||||
useStock,
|
||||
useStockCandles,
|
||||
useStockCompany,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export {
|
||||
MIN_STOCK_SEARCH_LENGTH,
|
||||
type StockCandles,
|
||||
type StockDividends,
|
||||
type StockPosition,
|
||||
useStock,
|
||||
useStockCandles,
|
||||
useStockCompany,
|
||||
|
||||
Reference in New Issue
Block a user