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:
2026-09-12 18:34:41 +03:00
parent 427ca23a43
commit ff4e88edbe
50 changed files with 7795 additions and 10 deletions
+10
View File
@@ -0,0 +1,10 @@
.card {
text-decoration: none;
color: inherit;
min-width: 0;
overflow-wrap: anywhere;
}
.card:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 3px;
}
+185
View File
@@ -0,0 +1,185 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { CardsPage } from "./CardsPage";
const money = (value: string) => ({ currency: "RUB", value });
function renderCards(fail = false, categoryFail = false) {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
categoryFail ? HttpResponse.error() : HttpResponse.json([]),
),
http.get("*/api/v1/bank-cards", () =>
fail ? HttpResponse.error() : HttpResponse.json([]),
),
http.get("*/api/v1/bank-cards/summary", () =>
HttpResponse.json({
month: "2026-09",
cardId: null,
expenses: money("0"),
refunds: money("0"),
net: money("0"),
categories: [],
}),
),
);
return render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter initialEntries={["/cards?month=2026-09"]}>
<CardsPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
}
describe("CardsPage", () => {
it("preserves exact negative opening amount and the creation intent through unknown failure", async () => {
renderCards();
await screen.findByRole("heading", { name: "Добавьте первую карту" });
fireEvent.click(
screen.getAllByRole("button", { name: "Добавить карту" })[0],
);
const dialog = screen.getByRole("dialog");
const submitted: Record<string, unknown>[] = [];
let resolve: (() => void) | undefined;
server.use(
http.post("*/api/v1/bank-cards", async ({ request }) => {
submitted.push((await request.json()) as Record<string, unknown>);
if (submitted.length === 1) {
await new Promise<void>((r) => {
resolve = r;
});
return HttpResponse.error();
}
return HttpResponse.json({ id: submitted[0].id }, { status: 201 });
}),
);
fireEvent.change(within(dialog).getByLabelText(/Название карты/), {
target: { value: " Повседневная " },
});
fireEvent.change(within(dialog).getByLabelText(/Начальный остаток/), {
target: { value: "1,001" },
});
fireEvent.click(within(dialog).getByRole("button", { name: "Сохранить" }));
expect(
await screen.findByText(/Введите сумму в допустимом диапазоне/),
).toBeInTheDocument();
expect(submitted).toHaveLength(0);
fireEvent.change(within(dialog).getByLabelText(/Начальный остаток/), {
target: { value: "-92233720368547758,08" },
});
fireEvent.change(within(dialog).getByLabelText(/Дата начала учёта/), {
target: { value: "2026-01-01" },
});
fireEvent.click(within(dialog).getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(submitted).toHaveLength(1));
expect(
within(dialog).getByRole("button", { name: "Отмена" }),
).toBeDisabled();
expect(
within(dialog).getByRole("button", { name: "Закрыть" }),
).toBeDisabled();
resolve?.();
await screen.findByText(/Не удалось подтвердить сохранение/);
expect(within(dialog).getByLabelText(/Начальный остаток/)).toHaveValue(
"-92233720368547758,08",
);
fireEvent.click(within(dialog).getByRole("button", { name: "Сохранить" }));
await waitFor(() =>
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
);
expect(submitted[0]).toEqual(submitted[1]);
expect(submitted[0]).toMatchObject({
name: "Повседневная",
bankName: null,
lastFour: null,
openingBalance: money("-92233720368547758.08"),
});
});
it("uses archive only for card list and keeps all-card summary including negative net", async () => {
renderCards();
const requests: string[] = [];
server.use(
http.get("*/api/v1/bank-cards", ({ request }) => {
requests.push(new URL(request.url).search);
return HttpResponse.json([
{
id: "demo",
name: "Запасная",
bankName: null,
lastFour: null,
balance: money("0"),
archivedAt: "2026-09-01",
version: 1,
openingBalance: money("0"),
openingDate: "2026-01-01",
},
]);
}),
);
fireEvent.click(screen.getByLabelText("Показать архив"));
await screen.findByText("Запасная");
expect(requests).toContain("?includeArchived=true");
expect(screen.getByLabelText("Месяц")).toHaveTextContent("September 2026");
expect(screen.getByRole("link", { name: /Запасная/ })).toHaveAttribute(
"href",
"/cards/demo?month=2026-09",
);
});
it("shows retry after failure instead of presenting an empty list", async () => {
renderCards(true);
// Mount starts asynchronously; this handler owns the request.
expect(await screen.findByRole("alert")).toHaveTextContent(
"Не удалось загрузить данные карт.",
);
expect(
screen.queryByRole("heading", { name: "Добавьте первую карту" }),
).not.toBeInTheDocument();
server.use(http.get("*/api/v1/bank-cards", () => HttpResponse.json([])));
fireEvent.click(
within(screen.getByRole("alert")).getByRole("button", {
name: "Повторить",
}),
);
await screen.findByRole("heading", { name: "Добавьте первую карту" });
});
it("makes failed category loading recoverable without hiding successful cards data", async () => {
renderCards(false, true);
await screen.findByRole("heading", { name: "Добавьте первую карту" });
expect(await screen.findByRole("alert")).toHaveTextContent(
"Не удалось загрузить данные карт.",
);
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([
{ id: "default", name: "Другое", version: 1, isDefault: true },
]),
),
);
fireEvent.click(
within(screen.getByRole("alert")).getByRole("button", {
name: "Повторить",
}),
);
await screen.findByRole("button", { name: "Категории" });
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
+19
View File
@@ -0,0 +1,19 @@
import type { FC } from "react";
import { BankCardForm, CategoryManager } from "@/entities/bank-card";
import { CardsPageView } from "./CardsPage.view";
import { useCardsPageVm } from "./CardsPage.vm";
const CardsPage: FC = () => {
const vm = useCardsPageVm();
return (
<>
<CardsPageView vm={vm} />
{vm.adding && <BankCardForm onClose={() => vm.setAdding(false)} />}
{vm.managingCategories && (
<CategoryManager onClose={() => vm.setManagingCategories(false)} />
)}
</>
);
};
export { CardsPage };
+137
View File
@@ -0,0 +1,137 @@
import {
Badge,
Button,
Group,
Paper,
SimpleGrid,
Skeleton,
Stack,
Switch,
Text,
Title,
} from "@mantine/core";
import { MonthPickerInput } from "@mantine/dates";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { BankFeedback, BankSummaryView } from "@/entities/bank-card";
import { formatMoney } from "@/shared/lib";
import { LoadingSkeleton, StateView } from "@/shared/ui";
import classes from "./CardsPage.module.css";
import type { CardsPageVm } from "./CardsPage.vm";
type CardsPageViewProps = { vm: CardsPageVm };
const CardsPageView: FC<CardsPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<Stack gap="lg">
<Group justify="space-between">
<Title order={1}>{t("bank.title")}</Title>
<Button onClick={() => vm.setAdding(true)}>{t("bank.addCard")}</Button>
</Group>
<Text c="dimmed">{t("bank.manualBalance")}</Text>
<Switch
label={t("bank.showArchive")}
checked={vm.archive}
onChange={(event) => vm.setArchive(event.currentTarget.checked)}
/>
{vm.items ? (
vm.items.length ? (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
{vm.items.map((card) => (
<Paper
key={card.id}
component={Link}
to={card.to}
withBorder
p="md"
className={classes.card}
>
<Stack gap="xs">
<Group>
<Text fw={600} size="lg">
{card.name}
</Text>
{card.archivedAt && (
<Badge color="gray">{t("bank.archived")}</Badge>
)}
</Group>
<Text c="dimmed">
{card.bankName}
{card.lastFour ? ` · •• ${card.lastFour}` : ""}
</Text>
<Text size="xl" fw={650}>
{formatMoney(card.balance)}
</Text>
</Stack>
</Paper>
))}
</SimpleGrid>
) : (
<StateView
title={t("bank.empty")}
text={t(vm.archive ? "bank.emptyHint" : "bank.noActive")}
action={
<Button onClick={() => vm.setAdding(true)}>
{t("bank.addCard")}
</Button>
}
/>
)
) : vm.cards.isLoading ? (
<LoadingSkeleton label={t("bank.loading")}>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Skeleton h={120} />
<Skeleton h={120} />
</SimpleGrid>
</LoadingSkeleton>
) : null}
{vm.cards.isError && (
<Group role="alert">
<Text>{t(vm.items ? "bank.refreshError" : "bank.error")}</Text>
<Button variant="default" onClick={() => void vm.cards.refetch()}>
{t("bank.retry")}
</Button>
</Group>
)}
{vm.cards.data && vm.cards.isFetching && (
<Text role="status">{t("bank.refreshing")}</Text>
)}
<MonthPickerInput
label={t("bank.month")}
value={`${vm.month}-01`}
onChange={(value) => {
if (value) vm.setMonth(value.slice(0, 7));
}}
popoverProps={{ middlewares: { shift: { crossAxis: true } } }}
/>
<BankSummaryView
all
data={vm.summary.data}
loading={vm.summary.isLoading}
error={vm.summary.isError}
fetching={vm.summary.isFetching}
retry={() => void vm.summary.refetch()}
/>
{vm.categories.isError && (
<BankFeedback
loadingError
error={vm.categories.error}
cached={Boolean(vm.categories.data)}
retry={() => void vm.categories.refetch()}
/>
)}
{Boolean(vm.categories.data?.length || vm.cards.data?.length) && (
<Button
variant="default"
onClick={() => vm.setManagingCategories(true)}
>
{t("bank.categories")}
</Button>
)}
</Stack>
);
};
export { CardsPageView };
+41
View File
@@ -0,0 +1,41 @@
import { useState } from "react";
import { useSearchParams } from "react-router";
import {
bankMonth,
useBankCards,
useBankCategories,
useBankSummary,
} from "@/entities/bank-card";
function useCardsPageVm() {
const [params, setParams] = useSearchParams();
const month = bankMonth(params.get("month"));
const [archive, setArchive] = useState(false);
const categories = useBankCategories();
const [managingCategories, setManagingCategories] = useState(false);
const [adding, setAdding] = useState(false);
const cards = useBankCards(archive);
const summary = useBankSummary(month);
return {
categories,
managingCategories,
setManagingCategories,
month,
archive,
setArchive,
adding,
setAdding,
cards,
summary,
setMonth: (value: string) => {
if (value) setParams({ month: bankMonth(value) });
},
items: cards.data?.map((card) => ({
...card,
to: `/cards/${card.id}?month=${month}`,
})),
};
}
type CardsPageVm = ReturnType<typeof useCardsPageVm>;
export { type CardsPageVm, useCardsPageVm };