refactor: clarify frontend module ownership
Frontend verify / commits (pull_request) Failing after 1m19s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped

This commit is contained in:
2026-09-29 07:07:36 +03:00
parent 7c28a423dd
commit f9f0f6921e
91 changed files with 1292 additions and 1050 deletions
+22 -18
View File
@@ -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),
+1 -1
View File
@@ -14,7 +14,7 @@ import type {
UpdateBankCard,
UpdateBankOperation,
UpdateExpenseCategory,
} from "./queries";
} from "./types";
async function mutationData<T>(
request: Promise<{ data?: T; error?: unknown }>,
+5 -56
View File
@@ -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,
+3 -1
View File
@@ -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 });
+48
View File
@@ -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,
};
+4 -4
View File
@@ -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 };
+2 -1
View File
@@ -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;
+26
View File
@@ -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 };