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:
@@ -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", () =>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
|
||||
import { AccountScopeSwitch } from "./AccountScopeSwitch";
|
||||
import { BondBreadcrumbs } from "./BondBreadcrumbs";
|
||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||
import { CardBreadcrumbs } from "./CardBreadcrumbs";
|
||||
import { StockBreadcrumbs } from "./StockBreadcrumbs";
|
||||
|
||||
const AppBreadcrumbs: FC = () => {
|
||||
@@ -17,6 +18,8 @@ const AppBreadcrumbs: FC = () => {
|
||||
const events = useMatch({ path: "/accounts/:accountId/events", end: true });
|
||||
const income = useMatch({ path: "/accounts/:accountId/income", end: true });
|
||||
const account = useMatch({ path: "/accounts/:accountId", end: true });
|
||||
const card = useMatch("/cards/:cardId");
|
||||
const cards = useMatch("/cards");
|
||||
const accounts = useMatch({ path: "/accounts", end: true });
|
||||
const settings = useMatch({ path: "/settings", end: true });
|
||||
|
||||
@@ -71,6 +74,8 @@ const AppBreadcrumbs: FC = () => {
|
||||
<AccountScopeSwitch accountId={account.params.accountId ?? ""} />
|
||||
</Stack>
|
||||
);
|
||||
if (card) return <CardBreadcrumbs identifier={card.params.cardId ?? ""} />;
|
||||
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
|
||||
if (accounts)
|
||||
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
|
||||
if (settings)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useBankCard } from "@/entities/bank-card";
|
||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||
|
||||
type CardBreadcrumbsProps = { identifier: string };
|
||||
const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => {
|
||||
const { data } = useBankCard(props.identifier);
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<BreadcrumbTrail
|
||||
items={[
|
||||
{ label: t("bank.title"), to: "/cards" },
|
||||
{ label: data?.name ?? t("bank.card") },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { CardBreadcrumbs };
|
||||
Reference in New Issue
Block a user