feat: add manual bank card screens
Add cards, operations, refunds, categories and monthly summaries with Mantine date controls. Derive banking types from runtime OpenAPI. Cover recovery and session isolation.
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { apiClient, configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { useBankMutations } from "./mutations";
|
||||
import {
|
||||
type BankCard,
|
||||
type CreateBankOperation,
|
||||
useBankCard,
|
||||
useBankCards,
|
||||
useBankCategories,
|
||||
useBankExpenses,
|
||||
useBankOperation,
|
||||
useBankOperations,
|
||||
useBankSummary,
|
||||
} from "./queries";
|
||||
|
||||
const card: BankCard = {
|
||||
id: "a",
|
||||
name: "Daily",
|
||||
bankName: null,
|
||||
lastFour: null,
|
||||
openingBalance: { currency: "RUB", value: "100" },
|
||||
openingDate: "2026-09-01",
|
||||
balance: { currency: "RUB", value: "100" },
|
||||
archivedAt: null,
|
||||
version: 1,
|
||||
};
|
||||
const transfer: CreateBankOperation = {
|
||||
id: "4e6caa65-dd52-4629-81e2-ce8a139a0612",
|
||||
kind: "transfer",
|
||||
cardId: "a",
|
||||
targetCardId: "b",
|
||||
amount: { currency: "RUB", value: "90071992547409.93" },
|
||||
date: "2026-09-12",
|
||||
};
|
||||
|
||||
function authenticate() {
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
function readHandlers(counts: Map<string, number>, categoryName = "Food") {
|
||||
return [
|
||||
http.get("*/api/v1/bank-cards", ({ request }) => {
|
||||
const key = new URL(request.url).pathname + new URL(request.url).search;
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
return HttpResponse.json([card]);
|
||||
}),
|
||||
http.get("*/api/v1/bank-cards/*", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const key = url.pathname + url.search;
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
if (url.pathname.endsWith("/categories"))
|
||||
return HttpResponse.json([
|
||||
{ id: "food", name: categoryName, isDefault: true, version: 1 },
|
||||
]);
|
||||
if (url.pathname.endsWith("/summary"))
|
||||
return HttpResponse.json({
|
||||
month: url.searchParams.get("month"),
|
||||
cardId: url.searchParams.get("cardId"),
|
||||
expenses: { currency: "RUB", value: "0" },
|
||||
refunds: { currency: "RUB", value: "0" },
|
||||
net: { currency: "RUB", value: "0" },
|
||||
categories: [],
|
||||
});
|
||||
if (
|
||||
url.pathname.endsWith("/operations") ||
|
||||
url.pathname.endsWith("/expenses")
|
||||
)
|
||||
return HttpResponse.json({
|
||||
items: [],
|
||||
page: 1,
|
||||
total: 0,
|
||||
hasNext: false,
|
||||
});
|
||||
if (url.pathname.includes("/operations/"))
|
||||
return HttpResponse.json({ ...transfer, version: 1 });
|
||||
return HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) });
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function useReadModels() {
|
||||
const cards = useBankCards(true);
|
||||
const a = useBankCard("a");
|
||||
const b = useBankCard("b");
|
||||
const categories = useBankCategories();
|
||||
const history = useBankOperations("a", {
|
||||
month: "2026-09",
|
||||
page: 2,
|
||||
limit: 20,
|
||||
});
|
||||
const expenses = useBankExpenses("a", { page: 1 });
|
||||
const september = useBankSummary("2026-09");
|
||||
const august = useBankSummary("2026-08", "b");
|
||||
const original = useBankOperation("original");
|
||||
return {
|
||||
reads: [
|
||||
cards,
|
||||
a,
|
||||
b,
|
||||
categories,
|
||||
history,
|
||||
expenses,
|
||||
september,
|
||||
august,
|
||||
original,
|
||||
],
|
||||
mutations: useBankMutations(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("banking API", () => {
|
||||
beforeEach(authenticate);
|
||||
|
||||
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => {
|
||||
const counts = new Map<string, number>();
|
||||
server.use(...readHandlers(counts));
|
||||
const { result } = renderHook(useReadModels, {
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
await waitFor(() =>
|
||||
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?month=2026-09&page=2&limit=20",
|
||||
);
|
||||
expect([...counts.keys()]).toContain(
|
||||
"/api/v1/bank-cards/summary?month=2026-08&cardId=b",
|
||||
);
|
||||
expect(counts.size).toBe(9);
|
||||
});
|
||||
|
||||
it("replays the exact UUID and large Money string once across shared auth refresh", async () => {
|
||||
let token = "expired";
|
||||
const refresh = vi.fn(async () => {
|
||||
token = "renewed";
|
||||
return true;
|
||||
});
|
||||
configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() });
|
||||
const bodies: unknown[] = [];
|
||||
const headers: (string | null)[] = [];
|
||||
server.use(
|
||||
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
headers.push(request.headers.get("Authorization"));
|
||||
return bodies.length === 1
|
||||
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
|
||||
: HttpResponse.json({ ...transfer, version: 1 });
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(useBankMutations, {
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
await act(() => result.current.createOperation.mutateAsync(transfer));
|
||||
expect(bodies).toEqual([transfer, transfer]);
|
||||
expect(headers).toEqual(["Bearer expired", "Bearer renewed"]);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not automatically retry a network failure and preserves the form-supplied intent on repeat", async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return bodies.length === 1
|
||||
? HttpResponse.error()
|
||||
: HttpResponse.json({ ...transfer, version: 1 });
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(useBankMutations, {
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.createOperation.mutateAsync(transfer),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
expect(bodies).toEqual([transfer]);
|
||||
await act(() => result.current.createOperation.mutateAsync(transfer));
|
||||
expect(bodies).toEqual([transfer, transfer]);
|
||||
});
|
||||
|
||||
it("keeps the confirmed balance while a mutation is pending or fails, and on refetch failure", async () => {
|
||||
server.use(...readHandlers(new Map()));
|
||||
let finish: () => void = () => {};
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.post("*/api/v1/bank-cards/operations", async () => {
|
||||
await pending;
|
||||
return HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(
|
||||
() => ({ query: useBankCard("a"), mutations: useBankMutations() }),
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
|
||||
let saving: Promise<unknown>;
|
||||
act(() => {
|
||||
saving = result.current.mutations.createOperation
|
||||
.mutateAsync(transfer)
|
||||
.catch(() => undefined);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current.mutations.createOperation.isPending).toBe(true),
|
||||
);
|
||||
expect(result.current.query.data?.balance.value).toBe("100");
|
||||
await act(async () => {
|
||||
finish();
|
||||
await saving;
|
||||
});
|
||||
expect(result.current.query.data?.balance.value).toBe("100");
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/a", () =>
|
||||
HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
await act(() => result.current.query.refetch());
|
||||
expect(result.current.query.isError).toBe(true);
|
||||
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 () => {
|
||||
const counts = new Map<string, number>();
|
||||
server.use(
|
||||
...readHandlers(counts),
|
||||
http.post("*/api/v1/bank-cards/operations", () =>
|
||||
HttpResponse.json({ ...transfer, version: 1 }),
|
||||
),
|
||||
);
|
||||
const { wrapper, queryClient } = createQueryWrapper();
|
||||
const brokerKey = ["get", "/api/v1/brokerage-accounts"];
|
||||
queryClient.setQueryData(brokerKey, []);
|
||||
const { result } = renderHook(useReadModels, { wrapper });
|
||||
await waitFor(() =>
|
||||
expect(result.current.reads.every((read) => read.isSuccess)).toBe(true),
|
||||
);
|
||||
await act(() =>
|
||||
result.current.mutations.createOperation.mutateAsync(transfer),
|
||||
);
|
||||
expect([...counts.values()]).toEqual(Array(9).fill(2));
|
||||
expect(queryClient.getQueryState(brokerKey)?.isInvalidated).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"BANK_VERSION_CONFLICT",
|
||||
"BANK_INTENT_CONFLICT",
|
||||
"BANK_REFUND_LIMIT",
|
||||
])("fetches current state after %s", async (code) => {
|
||||
let reads = 0;
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/a", () =>
|
||||
HttpResponse.json({ ...card, version: ++reads }),
|
||||
),
|
||||
http.patch("*/api/v1/bank-cards/a", () =>
|
||||
HttpResponse.json({ code }, { status: 409 }),
|
||||
),
|
||||
);
|
||||
const { result } = renderHook(
|
||||
() => ({ card: useBankCard("a"), mutations: useBankMutations() }),
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
await waitFor(() => expect(result.current.card.data?.version).toBe(1));
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.mutations.updateCard.mutateAsync({
|
||||
id: "a",
|
||||
body: { name: "changed", version: 1 },
|
||||
}),
|
||||
).rejects.toEqual({ code });
|
||||
});
|
||||
await waitFor(() => expect(result.current.card.data?.version).toBe(2));
|
||||
});
|
||||
|
||||
it("sends edit/delete versions and refreshes category names without optimistic updates", async () => {
|
||||
const requests: unknown[] = [];
|
||||
let name = "Food";
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/categories", () =>
|
||||
HttpResponse.json([{ id: "food", name, isDefault: true, version: 1 }]),
|
||||
),
|
||||
http.patch("*/api/v1/bank-cards/categories/food", async ({ request }) => {
|
||||
const body = await request.json();
|
||||
requests.push(body);
|
||||
name = "Groceries";
|
||||
return HttpResponse.json({
|
||||
id: "food",
|
||||
name,
|
||||
isDefault: true,
|
||||
version: 2,
|
||||
});
|
||||
}),
|
||||
http.patch(
|
||||
"*/api/v1/bank-cards/operations/original",
|
||||
async ({ request }) => {
|
||||
requests.push(await request.json());
|
||||
return HttpResponse.json({ ...transfer, version: 2 });
|
||||
},
|
||||
),
|
||||
http.delete("*/api/v1/bank-cards/operations/original", ({ request }) => {
|
||||
requests.push(new URL(request.url).searchParams.get("version"));
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
categories: useBankCategories(),
|
||||
mutations: useBankMutations(),
|
||||
}),
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.categories.data?.[0].name).toBe("Food"),
|
||||
);
|
||||
await act(() =>
|
||||
result.current.mutations.updateCategory.mutateAsync({
|
||||
id: "food",
|
||||
body: { name: "Groceries", version: 1 },
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.categories.data?.[0].name).toBe("Groceries"),
|
||||
);
|
||||
await act(() =>
|
||||
result.current.mutations.updateOperation.mutateAsync({
|
||||
id: "original",
|
||||
body: { description: "corrected", version: 1 },
|
||||
}),
|
||||
);
|
||||
await act(() =>
|
||||
result.current.mutations.deleteOperation.mutateAsync({
|
||||
id: "original",
|
||||
version: 2,
|
||||
}),
|
||||
);
|
||||
expect(requests).toEqual([
|
||||
{ name: "Groceries", version: 1 },
|
||||
{ description: "corrected", version: 1 },
|
||||
"2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("disables reads after session removal and retains banking data across a T-Bank API mutation", async () => {
|
||||
const counts = new Map<string, number>();
|
||||
server.use(
|
||||
...readHandlers(counts),
|
||||
http.put("*/api/v1/tbank-connection", () =>
|
||||
HttpResponse.json({
|
||||
connected: true,
|
||||
connectedAt: "2026-09-12T00:00:00Z",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { wrapper, queryClient } = createQueryWrapper();
|
||||
const { result, rerender } = renderHook(() => useBankCard("a"), {
|
||||
wrapper,
|
||||
});
|
||||
await waitFor(() => expect(result.current.data?.balance.value).toBe("100"));
|
||||
await apiClient.PUT("/api/v1/tbank-connection", {
|
||||
body: { token: "synthetic" },
|
||||
});
|
||||
expect(result.current.data?.balance.value).toBe("100");
|
||||
act(() => {
|
||||
configureAuthClient(null);
|
||||
queryClient.clear();
|
||||
});
|
||||
rerender();
|
||||
await waitFor(() => expect(result.current.fetchStatus).toBe("idle"));
|
||||
expect(result.current.data).toBeUndefined();
|
||||
expect(counts.get("/api/v1/bank-cards/a")).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiClient, apiErrorCode } from "@/shared/api";
|
||||
import type {
|
||||
CreateBankCard,
|
||||
CreateBankOperation,
|
||||
CreateExpenseCategory,
|
||||
UpdateBankCard,
|
||||
UpdateBankOperation,
|
||||
UpdateExpenseCategory,
|
||||
} from "./queries";
|
||||
|
||||
async function mutationData<T>(
|
||||
request: Promise<{ data?: T; error?: unknown }>,
|
||||
) {
|
||||
const { data, error } = await request;
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
function useBankMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({
|
||||
predicate: ({ queryKey: [method, path] }) =>
|
||||
method === "get" &&
|
||||
typeof path === "string" &&
|
||||
(path === "/api/v1/bank-cards" ||
|
||||
path.startsWith("/api/v1/bank-cards/")),
|
||||
});
|
||||
const options = {
|
||||
retry: false,
|
||||
onSuccess: invalidate,
|
||||
onError: (error: unknown) => {
|
||||
if (
|
||||
[
|
||||
"BANK_VERSION_CONFLICT",
|
||||
"BANK_WRITE_CONFLICT",
|
||||
"BANK_INTENT_CONFLICT",
|
||||
"BANK_REFUND_LIMIT",
|
||||
"BANK_DEPENDENT_REFUNDS",
|
||||
].includes(apiErrorCode(error) ?? "")
|
||||
)
|
||||
return invalidate();
|
||||
},
|
||||
} as const;
|
||||
const createCard = useMutation({
|
||||
...options,
|
||||
mutationFn: (body: CreateBankCard) =>
|
||||
mutationData(apiClient.POST("/api/v1/bank-cards", { body })),
|
||||
});
|
||||
const updateCard = useMutation({
|
||||
...options,
|
||||
mutationFn: ({ id, body }: { id: string; body: UpdateBankCard }) =>
|
||||
mutationData(
|
||||
apiClient.PATCH("/api/v1/bank-cards/{id}", {
|
||||
params: { path: { id } },
|
||||
body,
|
||||
}),
|
||||
),
|
||||
});
|
||||
const createOperation = useMutation({
|
||||
...options,
|
||||
mutationFn: (body: CreateBankOperation) =>
|
||||
mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })),
|
||||
});
|
||||
const updateOperation = useMutation({
|
||||
...options,
|
||||
mutationFn: ({ id, body }: { id: string; body: UpdateBankOperation }) =>
|
||||
mutationData(
|
||||
apiClient.PATCH("/api/v1/bank-cards/operations/{id}", {
|
||||
params: { path: { id } },
|
||||
body,
|
||||
}),
|
||||
),
|
||||
});
|
||||
const deleteOperation = useMutation({
|
||||
...options,
|
||||
mutationFn: ({ id, version }: { id: string; version: number }) =>
|
||||
mutationData(
|
||||
apiClient.DELETE("/api/v1/bank-cards/operations/{id}", {
|
||||
params: { path: { id }, query: { version } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
const createCategory = useMutation({
|
||||
...options,
|
||||
mutationFn: (body: CreateExpenseCategory) =>
|
||||
mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })),
|
||||
});
|
||||
const updateCategory = useMutation({
|
||||
...options,
|
||||
mutationFn: ({ id, body }: { id: string; body: UpdateExpenseCategory }) =>
|
||||
mutationData(
|
||||
apiClient.PATCH("/api/v1/bank-cards/categories/{id}", {
|
||||
params: { path: { id } },
|
||||
body,
|
||||
}),
|
||||
),
|
||||
});
|
||||
return {
|
||||
createCard,
|
||||
updateCard,
|
||||
createOperation,
|
||||
updateOperation,
|
||||
deleteOperation,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
};
|
||||
}
|
||||
|
||||
export { useBankMutations };
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
apiQueryClient,
|
||||
type paths,
|
||||
useAuthenticatedQuery,
|
||||
} 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 BankSummary =
|
||||
paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
|
||||
type ExpenseCategory =
|
||||
paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number];
|
||||
type CreateBankCard =
|
||||
paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"];
|
||||
type UpdateBankCard =
|
||||
paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"];
|
||||
type CreateBankOperation =
|
||||
paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"];
|
||||
type UpdateBankOperation =
|
||||
paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"];
|
||||
type CreateExpenseCategory =
|
||||
paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"];
|
||||
type UpdateExpenseCategory =
|
||||
paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"];
|
||||
|
||||
function useBankCards(includeArchived = false) {
|
||||
return useAuthenticatedQuery(
|
||||
apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
|
||||
params: {
|
||||
query: { includeArchived: includeArchived ? "true" : "false" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function useBankCard(id: string | undefined) {
|
||||
return useAuthenticatedQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/{id}", {
|
||||
params: { path: { id: id ?? "" } },
|
||||
}),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
function useBankCategories() {
|
||||
return useAuthenticatedQuery(
|
||||
apiQueryClient.queryOptions("get", "/api/v1/bank-cards/categories"),
|
||||
);
|
||||
}
|
||||
|
||||
function useBankOperations(
|
||||
id: string | undefined,
|
||||
query: { month: string; page: number; limit?: number },
|
||||
) {
|
||||
return useAuthenticatedQuery({
|
||||
...apiQueryClient.queryOptions(
|
||||
"get",
|
||||
"/api/v1/bank-cards/{id}/operations",
|
||||
{ params: { path: { id: id ?? "" }, query } },
|
||||
),
|
||||
enabled: Boolean(id && query.month),
|
||||
});
|
||||
}
|
||||
|
||||
function useBankExpenses(
|
||||
id: string | undefined,
|
||||
query: { page: number; limit?: number },
|
||||
) {
|
||||
return useAuthenticatedQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/{id}/expenses", {
|
||||
params: { path: { id: id ?? "" }, query },
|
||||
}),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
function useBankSummary(month: string, cardId?: string) {
|
||||
return useAuthenticatedQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", {
|
||||
params: { query: { month, cardId } },
|
||||
}),
|
||||
enabled: Boolean(month),
|
||||
});
|
||||
}
|
||||
|
||||
function useBankOperation(id: string | undefined) {
|
||||
return useAuthenticatedQuery({
|
||||
...apiQueryClient.queryOptions(
|
||||
"get",
|
||||
"/api/v1/bank-cards/operations/{id}",
|
||||
{ params: { path: { id: id ?? "" } } },
|
||||
),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
type BankCard,
|
||||
type BankOperation,
|
||||
type BankSummary,
|
||||
type CreateBankCard,
|
||||
type CreateBankOperation,
|
||||
type CreateExpenseCategory,
|
||||
type ExpenseCategory,
|
||||
type UpdateBankCard,
|
||||
type UpdateBankOperation,
|
||||
type UpdateExpenseCategory,
|
||||
useBankCard,
|
||||
useBankCards,
|
||||
useBankCategories,
|
||||
useBankExpenses,
|
||||
useBankOperation,
|
||||
useBankOperations,
|
||||
useBankSummary,
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
export { useBankMutations } from "./api/mutations";
|
||||
export {
|
||||
type BankCard,
|
||||
type BankOperation,
|
||||
useBankCard,
|
||||
useBankCards,
|
||||
useBankCategories,
|
||||
useBankExpenses,
|
||||
useBankOperation,
|
||||
useBankOperations,
|
||||
useBankSummary,
|
||||
} from "./api/queries";
|
||||
export {
|
||||
bankDateSchema,
|
||||
bankMoneyValue,
|
||||
bankMonth,
|
||||
bankToday,
|
||||
moneyCents,
|
||||
positiveMoneySchema,
|
||||
} from "./model/form-validation";
|
||||
export { BankCardForm } from "./ui/BankCardForm";
|
||||
export { BankFeedback } from "./ui/BankFeedback";
|
||||
export { BankSummaryView } from "./ui/BankSummary.view";
|
||||
export { CategoryManager } from "./ui/CategoryManager";
|
||||
export { useBankFormFocus } from "./ui/useBankFormFocus";
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
bankCardSchema,
|
||||
bankDateSchema,
|
||||
bankMoneyValue,
|
||||
bankMonth,
|
||||
bankToday,
|
||||
positiveMoneySchema,
|
||||
} from "./form-validation";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
describe("bank form input", () => {
|
||||
it("keeps input bounds exact without rounding and canonicalizes leading zeros", () => {
|
||||
expect(positiveMoneySchema.safeParse("92233720368547758.07").success).toBe(
|
||||
true,
|
||||
);
|
||||
for (const invalid of [
|
||||
"92233720368547758.08",
|
||||
"1.001",
|
||||
"0",
|
||||
"-1",
|
||||
"abc",
|
||||
"1e3",
|
||||
]) {
|
||||
expect(positiveMoneySchema.safeParse(invalid).success).toBe(false);
|
||||
}
|
||||
expect(bankMoneyValue(" -00010,20 ")).toBe("-10.20");
|
||||
const values = {
|
||||
name: "A",
|
||||
bankName: "",
|
||||
lastFour: "",
|
||||
openingBalance: "-92233720368547758.08",
|
||||
openingDate: "2026-01-01",
|
||||
};
|
||||
expect(bankCardSchema.safeParse(values).success).toBe(true);
|
||||
expect(
|
||||
bankCardSchema.safeParse({
|
||||
...values,
|
||||
openingBalance: "-92233720368547758.09",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
bankCardSchema.safeParse({ ...values, lastFour: "123" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
it("uses Moscow date and refuses nonexistent or future calendar days", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-31T21:30:00Z"));
|
||||
expect(bankToday()).toBe("2026-09-01");
|
||||
expect(bankMonth(null)).toBe("2026-09");
|
||||
expect(bankMonth("2026-13")).toBe("2026-09");
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { z } from "zod";
|
||||
import { validationMessage } from "@/shared/i18n";
|
||||
|
||||
function bankToday() {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Moscow",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(new Date());
|
||||
}
|
||||
function bankMonth(value: string | null) {
|
||||
return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
|
||||
? value
|
||||
: bankToday().slice(0, 7);
|
||||
}
|
||||
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{1,2})?$/.test(normalized)) return null;
|
||||
const [whole, fraction = ""] = normalized.replace("-", "").split(".");
|
||||
return (
|
||||
BigInt(`${whole}${fraction.padEnd(2, "0")}`) *
|
||||
(normalized.startsWith("-") ? -1n : 1n)
|
||||
);
|
||||
}
|
||||
const moneySchema = z.string().refine((value) => {
|
||||
const cents = moneyCents(value);
|
||||
return (
|
||||
cents !== null &&
|
||||
cents >= -9223372036854775808n &&
|
||||
cents <= 9223372036854775807n
|
||||
);
|
||||
}, validationMessage("bank.validation.money"));
|
||||
const positiveMoneySchema = moneySchema.refine((value) => {
|
||||
const cents = moneyCents(value);
|
||||
return cents === null || cents > 0n;
|
||||
}, validationMessage("bank.validation.positive"));
|
||||
const bankDateSchema = z
|
||||
.string()
|
||||
.refine(
|
||||
(value) =>
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
|
||||
value >= "0001-01-01" &&
|
||||
!Number.isNaN(Date.parse(`${value}T00:00:00Z`)) &&
|
||||
new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value &&
|
||||
value <= bankToday(),
|
||||
validationMessage("bank.validation.date"),
|
||||
);
|
||||
const bankCardSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, validationMessage("bank.validation.name")(undefined))
|
||||
.max(100, validationMessage("bank.validation.name")(undefined)),
|
||||
bankName: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(100, validationMessage("bank.validation.bankName")(undefined)),
|
||||
lastFour: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => !value || /^\d{4}$/.test(value),
|
||||
validationMessage("bank.validation.lastFour"),
|
||||
),
|
||||
openingBalance: moneySchema,
|
||||
openingDate: bankDateSchema,
|
||||
});
|
||||
type BankCardValues = z.infer<typeof bankCardSchema>;
|
||||
|
||||
export {
|
||||
type BankCardValues,
|
||||
bankCardSchema,
|
||||
bankDateSchema,
|
||||
bankMoneyValue,
|
||||
bankMonth,
|
||||
bankToday,
|
||||
moneyCents,
|
||||
positiveMoneySchema,
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import type { BankCardValues } from "../model/form-validation";
|
||||
import { BankCardFields } from "./BankCardForm.view";
|
||||
|
||||
const meta = {
|
||||
title: "Product/BankCards/CardFields",
|
||||
component: BankCardFields,
|
||||
tags: ["autodocs"],
|
||||
args: { archived: false },
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const methods = useForm<BankCardValues>({
|
||||
defaultValues: {
|
||||
name: "Учебная повседневная карта",
|
||||
bankName: "Вымышленный банк",
|
||||
lastFour: "1234",
|
||||
openingBalance: "-92233720368547758.08",
|
||||
openingDate: "2026-08-01",
|
||||
},
|
||||
});
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Story />
|
||||
</FormProvider>
|
||||
);
|
||||
},
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Поля ручной дебетовой карты на вымышленных данных. Сохранения и обращения к API нет. Архив оставляет описательные сведения доступными, а финансовые поля — только для чтения.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof BankCardFields>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Active: Story = { name: "Независимая карта в рублях" };
|
||||
export const Archived: Story = {
|
||||
name: "Финансовые поля архивной карты",
|
||||
args: { archived: true },
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import type { BankCard } from "../api/queries";
|
||||
import { BankCardForm } from "./BankCardForm";
|
||||
|
||||
const card: BankCard = {
|
||||
id: "a",
|
||||
name: "Основная",
|
||||
bankName: "Банк",
|
||||
lastFour: "1234",
|
||||
openingBalance: { currency: "RUB", value: "100" },
|
||||
balance: { currency: "RUB", value: "50" },
|
||||
openingDate: "2026-01-01",
|
||||
archivedAt: null,
|
||||
version: 1,
|
||||
};
|
||||
function setup(value: BankCard) {
|
||||
configureAuthClient({
|
||||
accessToken: () => "test",
|
||||
refresh: async () => false,
|
||||
clear: () => {},
|
||||
});
|
||||
server.use(http.get("*/api/v1/bank-cards/a", () => HttpResponse.json(value)));
|
||||
const close = vi.fn();
|
||||
render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider env="test">
|
||||
<BankCardForm card={value} onClose={close} />
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return close;
|
||||
}
|
||||
describe("BankCardForm", () => {
|
||||
it("clears optional metadata on archive while omitting protected financial fields", async () => {
|
||||
const close = setup({ ...card, archivedAt: "2026-09-01" });
|
||||
expect(screen.getByLabelText(/Начальный остаток/)).toHaveAttribute(
|
||||
"readonly",
|
||||
);
|
||||
expect(screen.getByLabelText(/Дата начала/)).toHaveAttribute("readonly");
|
||||
fireEvent.change(screen.getByLabelText(/Банк/), { target: { value: "" } });
|
||||
fireEvent.change(screen.getByLabelText(/Последние четыре/), {
|
||||
target: { value: "" },
|
||||
});
|
||||
let body: unknown;
|
||||
server.use(
|
||||
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json(card);
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await waitFor(() => expect(close).toHaveBeenCalled());
|
||||
expect(body).toEqual({
|
||||
version: 1,
|
||||
name: "Основная",
|
||||
bankName: null,
|
||||
lastFour: null,
|
||||
});
|
||||
});
|
||||
it("retains an invalid opening date draft and explains conflict with saved history", async () => {
|
||||
const close = setup(card);
|
||||
fireEvent.change(screen.getByLabelText(/Дата начала/), {
|
||||
target: { value: "2026-08-01" },
|
||||
});
|
||||
server.use(
|
||||
http.patch("*/api/v1/bank-cards/a", () =>
|
||||
HttpResponse.json(
|
||||
{ code: "BANK_OPENING_DATE_CONFLICT" },
|
||||
{ status: 400 },
|
||||
),
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await screen.findByText(
|
||||
"Дата начала противоречит сохранённой истории карты.",
|
||||
);
|
||||
expect(screen.getByLabelText(/Дата начала/)).toHaveValue("2026-08-01");
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps card edit version until explicit refresh adoption", async () => {
|
||||
const close = setup(card);
|
||||
let version = 1;
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/a", () =>
|
||||
HttpResponse.json({ ...card, version }),
|
||||
),
|
||||
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
bodies.push(body);
|
||||
version = 2;
|
||||
return body.version === 1
|
||||
? HttpResponse.json(
|
||||
{ code: "BANK_VERSION_CONFLICT" },
|
||||
{ status: 409 },
|
||||
)
|
||||
: HttpResponse.json(card);
|
||||
}),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText(/Название карты/), {
|
||||
target: { value: "Мой черновик" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await screen.findByText(/Запись уже изменена/);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" }));
|
||||
const adopt = await screen.findByRole("button", {
|
||||
name: "Использовать актуальную версию 2",
|
||||
});
|
||||
expect(screen.getByLabelText(/Название карты/)).toHaveValue("Мой черновик");
|
||||
fireEvent.click(adopt);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await waitFor(() => expect(close).toHaveBeenCalled());
|
||||
expect(bodies.map((b) => b.version)).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import { type FC, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useBankMutations } from "../api/mutations";
|
||||
import { type BankCard, useBankCard } from "../api/queries";
|
||||
import {
|
||||
type BankCardValues,
|
||||
bankCardSchema,
|
||||
bankMoneyValue,
|
||||
bankToday,
|
||||
} from "../model/form-validation";
|
||||
import { BankCardFields } from "./BankCardForm.view";
|
||||
import { BankFeedback } from "./BankFeedback";
|
||||
import classes from "./BankForms.module.css";
|
||||
import { useBankFormFocus } from "./useBankFormFocus";
|
||||
|
||||
type BankCardFormProps = { card?: BankCard; onClose: () => void };
|
||||
const BankCardForm: FC<BankCardFormProps> = (props) => {
|
||||
useBankFormFocus();
|
||||
const { t } = useTranslation();
|
||||
const [intent] = useState(() => crypto.randomUUID());
|
||||
const [version, setVersion] = useState(props.card?.version ?? 1);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [latest, setLatest] = useState<BankCard>();
|
||||
const fresh = useBankCard(props.card?.id);
|
||||
const mutation = useBankMutations();
|
||||
const methods = useForm<BankCardValues>({
|
||||
resolver: zodResolver(bankCardSchema),
|
||||
defaultValues: {
|
||||
name: props.card?.name ?? "",
|
||||
bankName: props.card?.bankName ?? "",
|
||||
lastFour: props.card?.lastFour ?? "",
|
||||
openingBalance: props.card?.openingBalance.value ?? "0",
|
||||
openingDate: props.card?.openingDate ?? bankToday(),
|
||||
},
|
||||
});
|
||||
const pending =
|
||||
methods.formState.isSubmitting || (fresh.isFetching && Boolean(error));
|
||||
return (
|
||||
<Modal
|
||||
opened
|
||||
onClose={() => !pending && props.onClose()}
|
||||
title={t(props.card ? "bank.editCard" : "bank.addCard")}
|
||||
closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }}
|
||||
closeOnClickOutside={!pending}
|
||||
closeOnEscape={!pending}
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
onSubmit={methods.handleSubmit(async (values) => {
|
||||
setError(undefined);
|
||||
try {
|
||||
const metadata = {
|
||||
name: values.name,
|
||||
bankName: values.bankName || null,
|
||||
lastFour: values.lastFour || null,
|
||||
};
|
||||
const financial = {
|
||||
openingBalance: {
|
||||
currency: "RUB" as const,
|
||||
value: bankMoneyValue(values.openingBalance),
|
||||
},
|
||||
openingDate: values.openingDate,
|
||||
};
|
||||
if (props.card)
|
||||
await mutation.updateCard.mutateAsync({
|
||||
id: props.card.id,
|
||||
body: {
|
||||
...metadata,
|
||||
...(!props.card.archivedAt ? financial : {}),
|
||||
version,
|
||||
},
|
||||
});
|
||||
else
|
||||
await mutation.createCard.mutateAsync({
|
||||
id: intent,
|
||||
...metadata,
|
||||
...financial,
|
||||
});
|
||||
props.onClose();
|
||||
} catch (failure) {
|
||||
setError(failure);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<fieldset className={classes.fieldset} disabled={pending}>
|
||||
<Stack>
|
||||
<BankCardFields archived={Boolean(props.card?.archivedAt)} />
|
||||
{error ? (
|
||||
<>
|
||||
<BankFeedback error={error} />
|
||||
{props.card && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
const response = await fresh.refetch();
|
||||
if (response.data && !response.error)
|
||||
setLatest(response.data);
|
||||
else setError(response.error);
|
||||
}}
|
||||
>
|
||||
{t("bank.reloadRecord")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{latest && (
|
||||
<>
|
||||
<Text>
|
||||
{t("bank.versionHint", {
|
||||
details: `${latest.name} · ${latest.openingBalance.value} RUB · ${latest.openingDate}`,
|
||||
})}
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setVersion(latest.version);
|
||||
setLatest(undefined);
|
||||
setError(undefined);
|
||||
}}
|
||||
>
|
||||
{t("bank.adoptVersion", { version: latest.version })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Group>
|
||||
<Button type="submit" loading={pending}>
|
||||
{t("bank.save")}
|
||||
</Button>
|
||||
<Button variant="default" onClick={props.onClose}>
|
||||
{t("bank.cancel")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</fieldset>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export { BankCardForm };
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Stack, Text, TextInput } from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import type { FC } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
type BankCardValues,
|
||||
bankDateSchema,
|
||||
bankToday,
|
||||
} from "../model/form-validation";
|
||||
|
||||
type BankCardFieldsProps = { archived: boolean };
|
||||
const BankCardFields: FC<BankCardFieldsProps> = (props) => {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<BankCardValues>();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("bank.name")}
|
||||
required
|
||||
{...register("name")}
|
||||
error={errors.name?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("bank.bankName")}
|
||||
{...register("bankName")}
|
||||
error={errors.bankName?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("bank.lastFour")}
|
||||
inputMode="numeric"
|
||||
{...register("lastFour")}
|
||||
error={errors.lastFour?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("bank.openingBalance")}
|
||||
required
|
||||
inputMode="decimal"
|
||||
readOnly={props.archived}
|
||||
{...register("openingBalance")}
|
||||
error={errors.openingBalance?.message}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="openingDate"
|
||||
render={({ field }) => (
|
||||
<DateInput
|
||||
{...field}
|
||||
label={t("bank.openingDate")}
|
||||
required
|
||||
valueFormat="YYYY-MM-DD"
|
||||
value={
|
||||
bankDateSchema.safeParse(field.value).success ? field.value : null
|
||||
}
|
||||
dateParser={(value) =>
|
||||
bankDateSchema.safeParse(value).success ? value : null
|
||||
}
|
||||
fixOnBlur={false}
|
||||
onInput={(event) => field.onChange(event.currentTarget.value)}
|
||||
onChange={(value) => field.onChange(value ?? "")}
|
||||
onBlur={(event) => {
|
||||
field.onChange(event.currentTarget.value);
|
||||
field.onBlur();
|
||||
}}
|
||||
maxDate={bankToday()}
|
||||
popoverProps={{
|
||||
withinPortal: false,
|
||||
middlewares: { shift: { crossAxis: true } },
|
||||
}}
|
||||
readOnly={props.archived}
|
||||
error={errors.openingDate?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("bank.openingHint")}
|
||||
</Text>
|
||||
{props.archived && <Text>{t("bank.archiveHint")}</Text>}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { BankCardFields };
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Alert, Button, Group, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
|
||||
type BankFeedbackProps = {
|
||||
error: unknown;
|
||||
retry?: () => void;
|
||||
cached?: boolean;
|
||||
loadingError?: boolean;
|
||||
};
|
||||
const BankFeedback: FC<BankFeedbackProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const code = apiErrorCode(props.error);
|
||||
return (
|
||||
<Alert color="orange" role="alert">
|
||||
<Group>
|
||||
<Text>
|
||||
{props.cached
|
||||
? t("bank.refreshError")
|
||||
: t(`bank.errors.${code}` as "bank.errors.BANK_INVALID_INPUT", {
|
||||
defaultValue: t(
|
||||
props.loadingError ? "bank.error" : "bank.requestError",
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
{props.retry && (
|
||||
<Button variant="default" onClick={props.retry}>
|
||||
{t("bank.retry")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export { BankFeedback };
|
||||
@@ -0,0 +1,6 @@
|
||||
.fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { BankSummaryView } from "./BankSummary.view";
|
||||
|
||||
const money = (value: string) => ({ currency: "RUB", value });
|
||||
const meta = {
|
||||
title: "Product/BankCards/Summary",
|
||||
component: BankSummaryView,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Месячные расходы ручного учёта. Вымышленные суммы, без API и сохранения. Возвраты учитываются отдельно; отрицательный итог не превращается в процентную диаграмму.",
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
loading: false,
|
||||
error: false,
|
||||
fetching: false,
|
||||
retry: () => {},
|
||||
data: {
|
||||
month: "2026-09",
|
||||
cardId: null,
|
||||
expenses: money("8500"),
|
||||
refunds: money("1000"),
|
||||
net: money("7500"),
|
||||
categories: [
|
||||
{
|
||||
categoryId: "food",
|
||||
categoryName: "Продукты",
|
||||
expenses: money("8500"),
|
||||
refunds: money("1000"),
|
||||
net: money("7500"),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof BankSummaryView>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Loaded: Story = { name: "Расходы и возвраты" };
|
||||
export const RefundMonth: Story = {
|
||||
name: "Возврат покупки прошлого месяца",
|
||||
args: {
|
||||
data: {
|
||||
month: "2026-09",
|
||||
cardId: null,
|
||||
expenses: money("0"),
|
||||
refunds: money("600"),
|
||||
net: money("-600"),
|
||||
categories: [
|
||||
{
|
||||
categoryId: "shopping",
|
||||
categoryName:
|
||||
"Покупки с длинным пользовательским названием категории",
|
||||
expenses: money("0"),
|
||||
refunds: money("600"),
|
||||
net: money("-600"),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
export const Empty: Story = {
|
||||
name: "Известные нули",
|
||||
args: {
|
||||
data: {
|
||||
month: "2026-09",
|
||||
cardId: null,
|
||||
expenses: money("0"),
|
||||
refunds: money("0"),
|
||||
net: money("0"),
|
||||
categories: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
export const Loading: Story = {
|
||||
name: "Начальная загрузка",
|
||||
args: { data: undefined, loading: true },
|
||||
};
|
||||
export const Failed: Story = {
|
||||
name: "Ошибка без данных",
|
||||
args: { data: undefined, error: true },
|
||||
};
|
||||
export const CachedError: Story = {
|
||||
name: "Сохранённые суммы при ошибке обновления",
|
||||
args: { error: true },
|
||||
};
|
||||
export const LongAmounts: Story = {
|
||||
name: "Точный итог длиннее предела отдельного ввода",
|
||||
args: {
|
||||
data: {
|
||||
month: "2026-09",
|
||||
cardId: null,
|
||||
expenses: money("184467440737095516.14"),
|
||||
refunds: money("0"),
|
||||
net: money("184467440737095516.14"),
|
||||
categories: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import { LoadingSkeleton } from "@/shared/ui";
|
||||
import type { BankSummary } from "../api/queries";
|
||||
|
||||
type BankSummaryViewProps = {
|
||||
data?: BankSummary;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
fetching: boolean;
|
||||
retry: () => void;
|
||||
all?: boolean;
|
||||
};
|
||||
const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Paper p="md" withBorder>
|
||||
<Stack>
|
||||
<Title order={2}>
|
||||
{t(props.all ? "bank.allSummary" : "bank.summary")}
|
||||
</Title>
|
||||
{props.data ? (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
{(["expenses", "refunds", "net"] as const).map((key) => (
|
||||
<div key={key}>
|
||||
<Text c="dimmed">{t(`bank.${key}`)}</Text>
|
||||
<Text size="xl" fw={650} style={{ overflowWrap: "anywhere" }}>
|
||||
{formatMoney(props.data?.[key] ?? null)}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("bank.summaryHint")}
|
||||
</Text>
|
||||
{props.data.categories.length ? (
|
||||
props.data.categories.map((category) => (
|
||||
<Paper p="sm" key={category.categoryId} withBorder>
|
||||
<Text fw={600}>{category.categoryName}</Text>
|
||||
<Group>
|
||||
{(["expenses", "refunds", "net"] as const).map((key) => (
|
||||
<Text key={key} style={{ overflowWrap: "anywhere" }}>
|
||||
{t(`bank.${key}`)}: {formatMoney(category[key])}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
</Paper>
|
||||
))
|
||||
) : (
|
||||
<Text>{t("bank.noExpenses")}</Text>
|
||||
)}
|
||||
</>
|
||||
) : props.loading ? (
|
||||
<LoadingSkeleton label={t("bank.loading")}>
|
||||
<SimpleGrid cols={3}>
|
||||
<Skeleton h={60} />
|
||||
<Skeleton h={60} />
|
||||
<Skeleton h={60} />
|
||||
</SimpleGrid>
|
||||
</LoadingSkeleton>
|
||||
) : null}
|
||||
{props.error && (
|
||||
<Group role="alert">
|
||||
<Text>{t(props.data ? "bank.refreshError" : "bank.error")}</Text>
|
||||
<Button variant="default" onClick={props.retry}>
|
||||
{t("bank.retry")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
{props.data && props.fetching && (
|
||||
<Text role="status">{t("bank.refreshing")}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { BankSummaryView };
|
||||
@@ -0,0 +1,74 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { CategoryManager } from "./CategoryManager";
|
||||
|
||||
describe("CategoryManager", () => {
|
||||
it("rejects duplicate case-insensitive and too long names then renames default identity", async () => {
|
||||
configureAuthClient({
|
||||
accessToken: () => "test",
|
||||
refresh: async () => false,
|
||||
clear: () => {},
|
||||
});
|
||||
let category = {
|
||||
id: "default",
|
||||
name: "Другое",
|
||||
isDefault: true,
|
||||
version: 1,
|
||||
};
|
||||
let creates = 0;
|
||||
let body: unknown;
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/categories", () =>
|
||||
HttpResponse.json([category]),
|
||||
),
|
||||
http.post("*/api/v1/bank-cards/categories", () => {
|
||||
creates++;
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.patch(
|
||||
"*/api/v1/bank-cards/categories/default",
|
||||
async ({ request }) => {
|
||||
body = await request.json();
|
||||
category = { ...category, name: "Остальное", version: 2 };
|
||||
return HttpResponse.json(category);
|
||||
},
|
||||
),
|
||||
);
|
||||
render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider env="test">
|
||||
<CategoryManager onClose={() => {}} />
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("Другое");
|
||||
fireEvent.change(screen.getByLabelText(/Название категории/), {
|
||||
target: { value: " другое " },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Добавить категорию" }));
|
||||
await screen.findByText("Категория с таким названием уже есть.");
|
||||
fireEvent.change(screen.getByLabelText(/Название категории/), {
|
||||
target: { value: "a".repeat(81) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Добавить категорию" }));
|
||||
await screen.findByText("Введите название от 1 до 80 символов.");
|
||||
expect(creates).toBe(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Переименовать" }));
|
||||
fireEvent.input(screen.getByLabelText(/Название категории/), {
|
||||
target: { value: "Остальное" },
|
||||
});
|
||||
fireEvent.blur(screen.getByLabelText(/Название категории/));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await screen.findByText("Остальное");
|
||||
await waitFor(() =>
|
||||
expect(body).toEqual({ name: "Остальное", version: 1 }),
|
||||
);
|
||||
expect(screen.getByText("По умолчанию")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { type FC, useState } from "react";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useForm,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { validationMessage } from "@/shared/i18n";
|
||||
import { useBankMutations } from "../api/mutations";
|
||||
import { type ExpenseCategory, useBankCategories } from "../api/queries";
|
||||
import { BankFeedback } from "./BankFeedback";
|
||||
import classes from "./BankForms.module.css";
|
||||
import { useBankFormFocus } from "./useBankFormFocus";
|
||||
|
||||
type CategoryManagerProps = {
|
||||
onClose: () => void;
|
||||
onCreated?: (id: string) => void;
|
||||
};
|
||||
type CategoryValues = { name: string };
|
||||
const CategoryFields: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext<CategoryValues>();
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
label={t("bank.categoryName")}
|
||||
required
|
||||
{...field}
|
||||
error={errors.name?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
||||
useBankFormFocus();
|
||||
const { t } = useTranslation();
|
||||
const categories = useBankCategories();
|
||||
const mutation = useBankMutations();
|
||||
const [editing, setEditing] = useState<ExpenseCategory>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [latest, setLatest] = useState<ExpenseCategory>();
|
||||
const methods = useForm<CategoryValues>({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
(value) => value.length > 0 && value.length <= 80,
|
||||
validationMessage("bank.validation.category"),
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
!categories.data?.some(
|
||||
(c) =>
|
||||
c.id !== editing?.id &&
|
||||
c.name.toLocaleLowerCase("ru-RU") ===
|
||||
value.toLocaleLowerCase("ru-RU"),
|
||||
),
|
||||
validationMessage("bank.validation.duplicate"),
|
||||
),
|
||||
}),
|
||||
),
|
||||
defaultValues: { name: "" },
|
||||
});
|
||||
const pending = methods.formState.isSubmitting;
|
||||
return (
|
||||
<Modal
|
||||
opened
|
||||
onClose={() => !pending && props.onClose()}
|
||||
title={t("bank.categories")}
|
||||
closeOnClickOutside={!pending}
|
||||
closeOnEscape={!pending}
|
||||
closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }}
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
onSubmit={methods.handleSubmit(async (values) => {
|
||||
setError(undefined);
|
||||
try {
|
||||
if (editing)
|
||||
await mutation.updateCategory.mutateAsync({
|
||||
id: editing.id,
|
||||
body: { name: values.name, version: editing.version },
|
||||
});
|
||||
else {
|
||||
const result = await mutation.createCategory.mutateAsync({
|
||||
name: values.name,
|
||||
});
|
||||
if (result) props.onCreated?.(result.id);
|
||||
}
|
||||
setEditing(undefined);
|
||||
methods.reset({ name: "" });
|
||||
} catch (failure) {
|
||||
setError(failure);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<fieldset disabled={pending} className={classes.fieldset}>
|
||||
<Stack>
|
||||
{categories.data?.map((category) => (
|
||||
<Group justify="space-between" key={category.id}>
|
||||
<Group gap="xs">
|
||||
<Text>{category.name}</Text>
|
||||
{category.isDefault && (
|
||||
<Badge>{t("bank.defaultCategory")}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
setEditing(category);
|
||||
methods.reset({ name: category.name });
|
||||
setError(undefined);
|
||||
setLatest(undefined);
|
||||
}}
|
||||
>
|
||||
{t("bank.rename")}
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
{categories.isLoading && (
|
||||
<Text role="status">{t("bank.loading")}</Text>
|
||||
)}
|
||||
{categories.isError && (
|
||||
<BankFeedback
|
||||
loadingError
|
||||
error={categories.error}
|
||||
cached={Boolean(categories.data)}
|
||||
retry={() => void categories.refetch()}
|
||||
/>
|
||||
)}
|
||||
<CategoryFields />
|
||||
{error ? (
|
||||
<>
|
||||
<BankFeedback error={error} />
|
||||
{editing && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
const response = await categories.refetch();
|
||||
if (response.error) setError(response.error);
|
||||
else
|
||||
setLatest(
|
||||
response.data?.find((c) => c.id === editing.id),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("bank.reloadRecord")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{latest && (
|
||||
<>
|
||||
<Text>{t("bank.versionHint", { details: latest.name })}</Text>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(latest);
|
||||
setLatest(undefined);
|
||||
setError(undefined);
|
||||
}}
|
||||
>
|
||||
{t("bank.adoptVersion", { version: latest.version })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Group>
|
||||
<Button type="submit" loading={pending}>
|
||||
{t(editing ? "bank.save" : "bank.addCategory")}
|
||||
</Button>
|
||||
{editing && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setEditing(undefined);
|
||||
methods.reset({ name: "" });
|
||||
setError(undefined);
|
||||
}}
|
||||
>
|
||||
{t("bank.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" onClick={props.onClose}>
|
||||
{t("bank.close")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</fieldset>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export { CategoryManager };
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function useBankFormFocus() {
|
||||
const [trigger] = useState(() => document.activeElement);
|
||||
useEffect(
|
||||
() => () => {
|
||||
queueMicrotask(() => {
|
||||
if (trigger instanceof HTMLElement && trigger.isConnected)
|
||||
trigger.focus();
|
||||
});
|
||||
},
|
||||
[trigger],
|
||||
);
|
||||
}
|
||||
|
||||
export { useBankFormFocus };
|
||||
Reference in New Issue
Block a user