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
+36
View File
@@ -37,6 +37,7 @@ describe("AppRoutes", () => {
["/accounts/demo", "Вход"],
["/accounts/demo/operations", "Вход"],
["/settings", "Вход"],
["/cards?month=2026-08", "Вход"],
["/unknown", "Страница не найдена"],
])("renders %s with its page title", async (path, title) => {
renderRoutes(path);
@@ -94,6 +95,41 @@ describe("AppRoutes", () => {
});
});
it("returns after login to cards with the selected month without T-Bank", async () => {
server.use(
http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([])),
http.post("*/api/v1/auth/login", () =>
HttpResponse.json({
accessToken: "test",
user: { id: "user-1", email: "user@example.com" },
}),
),
http.get("*/api/v1/bank-cards", () => HttpResponse.json([])),
http.get("*/api/v1/bank-cards/summary", () =>
HttpResponse.json({
month: "2026-08",
cardId: null,
expenses: { currency: "RUB", value: "0" },
refunds: { currency: "RUB", value: "0" },
net: { currency: "RUB", value: "0" },
categories: [],
}),
),
);
renderRoutes("/cards?month=2026-08");
await screen.findByRole("heading", { name: "Вход" });
fireEvent.change(screen.getByLabelText("Email"), {
target: { value: "user@example.com" },
});
fireEvent.change(screen.getByLabelText("Пароль"), {
target: { value: "a secure password" },
});
fireEvent.click(screen.getByRole("button", { name: "Вход" }));
await screen.findByRole("heading", { name: "Добавьте первую карту" });
expect(screen.getByLabelText("Месяц")).toHaveTextContent("August 2026");
expect(document.title).toBe("Карты — WealthLedger");
});
it("lazy-loads the protected shell for an authenticated accounts route", async () => {
server.use(
http.post("*/api/v1/auth/refresh", () =>
+24
View File
@@ -4,6 +4,14 @@ import { Navigate, Route, Routes } from "react-router";
import { LazyPage } from "./RouteFallback";
import { Protected, Public } from "./RouteGuards";
const CardPage = lazy(async () => {
const { CardPage } = await import("@/pages/card");
return { default: CardPage };
});
const CardsPage = lazy(async () => {
const { CardsPage } = await import("@/pages/cards");
return { default: CardsPage };
});
const BondPage = lazy(async () => {
const { BondPage } = await import("@/pages/bond");
return { default: BondPage };
@@ -93,6 +101,22 @@ const AppRoutes: FC = () => (
</Protected>
}
>
<Route
path="/cards/:cardId"
element={
<LazyPage variant="content">
<CardPage />
</LazyPage>
}
/>
<Route
path="/cards"
element={
<LazyPage variant="content">
<CardsPage />
</LazyPage>
}
/>
<Route
path="/bonds"
element={
+19 -7
View File
@@ -2,6 +2,7 @@ import { useDocumentTitle } from "@mantine/hooks";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useMatch } from "react-router";
import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { useStock } from "@/entities/stock";
@@ -52,14 +53,23 @@ const StockDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
return <DocumentTitle title={data?.name ?? t("stock.share")} />;
};
const CardDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
const { t } = useTranslation();
const { data } = useBankCard(props.identifier);
return <DocumentTitle title={data?.name ?? t("bank.card")} />;
};
const ProtectedDocumentTitle: FC = () => {
const { t } = useTranslation();
const account = useMatch("/accounts/:accountId/:section?");
const bond = useMatch("/bonds/:bondId");
const stock = useMatch("/stocks/:stockId");
const card = useMatch("/cards/:cardId");
const cards = useMatch("/cards");
const bonds = useMatch("/bonds");
const stocks = useMatch("/stocks");
const settings = useMatch("/settings");
if (card) return <CardDocumentTitle identifier={card.params.cardId ?? ""} />;
if (account)
return (
<AccountDocumentTitle
@@ -73,13 +83,15 @@ const ProtectedDocumentTitle: FC = () => {
return (
<DocumentTitle
title={t(
bonds
? "navigation.bonds"
: stocks
? "navigation.stocks"
: settings
? "navigation.settings"
: "navigation.accounts",
cards
? "navigation.cards"
: bonds
? "navigation.bonds"
: stocks
? "navigation.stocks"
: settings
? "navigation.settings"
: "navigation.accounts",
)}
/>
);
+9 -1
View File
@@ -22,8 +22,16 @@ const Protected: FC<PropsWithChildren> = (props) => {
const Public: FC<PropsWithChildren> = (props) => {
const { children } = props;
const { user, ready } = useAuth();
const location = useLocation();
const requested = (location.state as { returnTo?: string } | null)?.returnTo;
const returnTo =
typeof requested === "string" &&
requested.startsWith("/") &&
!requested.startsWith("//")
? requested
: "/accounts";
if (!ready) return <RouteSkeleton variant="shell" />;
return user ? <Navigate to="/accounts" replace /> : children;
return user ? <Navigate to={returnTo} replace /> : children;
};
export { Protected, Public };