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
+226
View File
@@ -0,0 +1,226 @@
import {
Anchor,
Badge,
Button,
Group,
Paper,
Skeleton,
Stack,
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 { EntryCard, LoadingSkeleton, StateView } from "@/shared/ui";
import classes from "./CardPage.module.css";
import type { CardPageVm } from "./CardPage.vm";
type CardPageViewProps = { vm: CardPageVm };
const CardPageView: FC<CardPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const card = vm.card.data;
if (!card) {
if (vm.card.isLoading)
return (
<LoadingSkeleton label={t("bank.loading")}>
<Stack>
<Skeleton h={130} />
<Skeleton h={160} />
<Skeleton h={90} />
<Skeleton h={90} />
</Stack>
</LoadingSkeleton>
);
return (
<StateView
error
title={t(vm.notFound ? "bank.notFound" : "bank.error")}
text={t("bank.back")}
action={
<Group>
<Button component={Link} to="/cards">
{t("bank.back")}
</Button>
{!vm.notFound && (
<Button onClick={() => void vm.card.refetch()}>
{t("bank.retry")}
</Button>
)}
</Group>
}
/>
);
}
return (
<Stack gap="lg">
<Paper p="md" withBorder>
<Stack>
<Group justify="space-between">
<Title order={1} className={classes.wrap}>
{card.name}
</Title>
{card.archivedAt && (
<Badge color="gray">{t("bank.archived")}</Badge>
)}
</Group>
<Text c="dimmed">
{card.bankName}
{card.lastFour ? ` · •• ${card.lastFour}` : ""}
</Text>
<Text size="xl" fw={700} className={classes.wrap}>
{formatMoney(card.balance)}
</Text>
<Text c="dimmed">{t("bank.manualBalance")}</Text>
<Group>
<Button
disabled={Boolean(card.archivedAt) || !vm.cards.data}
onClick={() => vm.setOperation("new")}
>
{t("bank.addOperation")}
</Button>
<Button variant="default" onClick={() => vm.setEditingCard(true)}>
{t("bank.editCard")}
</Button>
<Button
variant="default"
loading={vm.archivePending}
onClick={() => void vm.toggleArchive()}
>
{t(card.archivedAt ? "bank.restore" : "bank.archive")}
</Button>
</Group>
{vm.archiveError ? (
<BankFeedback
error={vm.archiveError}
retry={() => void vm.card.refetch()}
/>
) : null}
{card.archivedAt && <Text>{t("bank.archiveHint")}</Text>}
</Stack>
</Paper>
{vm.card.isError && (
<Group role="alert">
<Text>{t("bank.refreshError")}</Text>
<Button onClick={() => void vm.card.refetch()}>
{t("bank.retry")}
</Button>
</Group>
)}
{vm.card.isFetching && <Text role="status">{t("bank.refreshing")}</Text>}
{vm.cards.isError && (
<BankFeedback
loadingError
error={vm.cards.error}
cached={Boolean(vm.cards.data)}
retry={() => void vm.cards.refetch()}
/>
)}
<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
data={vm.summary.data}
loading={vm.summary.isLoading}
fetching={vm.summary.isFetching}
error={vm.summary.isError}
retry={() => void vm.summary.refetch()}
/>
<Title order={2}>{t("bank.operations")}</Title>
{vm.rows ? (
vm.rows.length ? (
vm.rows.map((row) => (
<section
key={row.item.id}
className={classes.operation}
aria-label={`${row.title} ${row.item.date}`}
>
<EntryCard
title={row.title}
metadata={row.metadata}
initial={row.title[0]}
value={row.value}
valueLabel={t(`bank.kinds.${row.item.kind}`)}
tone={row.tone}
details={
row.otherCard ? (
<Anchor
component={Link}
to={`/cards/${row.otherCard}?month=${vm.month}`}
>
{t("bank.card")}
</Anchor>
) : null
}
/>
<Group mt="xs">
<Button
variant="subtle"
disabled={!row.editable || Boolean(card.archivedAt)}
onClick={() => vm.setOperation(row.item)}
>
{t("bank.edit")}
</Button>
<Button
variant="subtle"
color="red"
disabled={!row.editable || Boolean(card.archivedAt)}
onClick={() => vm.setDeleting(row.item)}
>
{t("bank.delete")}
</Button>
</Group>
</section>
))
) : (
<Text>{t("bank.noOperations")}</Text>
)
) : vm.history.isLoading ? (
<LoadingSkeleton label={t("bank.loadingHistory")}>
<Stack>
<Skeleton h={90} />
<Skeleton h={90} />
</Stack>
</LoadingSkeleton>
) : null}
{vm.history.isError && (
<Group role="alert">
<Text>{t(vm.rows ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={() => void vm.history.refetch()}>
{t("bank.retry")}
</Button>
</Group>
)}
{vm.history.data && (
<Group>
<Button
variant="default"
disabled={vm.page === 1 || vm.history.isFetching}
onClick={() => vm.setPage(vm.page - 1)}
>
{t("bank.previous")}
</Button>
<Text>{t("bank.page", { page: vm.page })}</Text>
<Button
variant="default"
disabled={!vm.history.data.hasNext || vm.history.isFetching}
onClick={() => vm.setPage(vm.page + 1)}
>
{t("bank.next")}
</Button>
</Group>
)}
</Stack>
);
};
export { CardPageView };