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,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 };
|
||||
Reference in New Issue
Block a user