diff --git a/src/entities/bank-card/api/queries.ts b/src/entities/bank-card/api/queries.ts index a9dc83d..0a69d0a 100644 --- a/src/entities/bank-card/api/queries.ts +++ b/src/entities/bank-card/api/queries.ts @@ -5,6 +5,8 @@ import { useAuthenticatedQuery, } from "@/shared/api"; +import { moneyCents } from "../model/form-validation"; + type BankCard = paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"]; type BankOperation = @@ -119,11 +121,25 @@ function useBankExpenses( }); } +function sortBankSummary(data: BankSummary): BankSummary { + return { + ...data, + categories: data.categories.toSorted((a, b) => { + const left = moneyCents(a.net.value); + const right = moneyCents(b.net.value); + if (left === null || right === null) + throw new Error("Invalid bank summary amount"); + return left > right ? -1 : left < right ? 1 : 0; + }), + }; +} + function useBankSummary(month: string, cardId?: string) { return useAuthenticatedQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", { params: { query: { month, cardId } }, }), + select: sortBankSummary, enabled: Boolean(month), }); } @@ -147,6 +163,7 @@ export { type CreateBankOperation, type CreateExpenseCategory, type ExpenseCategory, + sortBankSummary, type UpdateBankCard, type UpdateBankOperation, type UpdateExpenseCategory, diff --git a/src/entities/bank-card/api/summary.test.ts b/src/entities/bank-card/api/summary.test.ts new file mode 100644 index 0000000..a5bcf75 --- /dev/null +++ b/src/entities/bank-card/api/summary.test.ts @@ -0,0 +1,32 @@ +import { expect, it } from "vitest"; +import { type BankSummary, sortBankSummary } from "./queries"; + +it("orders displayed expenses descending, preserving cents, ties and source data", () => { + const values = [ + "-10", + "9007199254740992.01", + "0", + "9007199254740992.02", + "-2", + "0", + ]; + const money = (value: string) => ({ currency: "RUB", value }); + const data: BankSummary = { + month: "2026-09", + cardId: "a", + expenses: money("0"), + refunds: money("0"), + net: money("0"), + categories: values.map((value, index) => ({ + categoryId: String(index), + categoryName: String(index), + expenses: money("100"), + refunds: money("0"), + net: money(value), + })), + }; + expect(sortBankSummary(data).categories.map((row) => row.categoryId)).toEqual( + ["3", "1", "2", "5", "4", "0"], + ); + expect(data.categories.map((row) => row.net.value)).toEqual(values); +}); diff --git a/src/entities/bank-card/ui/BankSummary.module.css b/src/entities/bank-card/ui/BankSummary.module.css new file mode 100644 index 0000000..0944833 --- /dev/null +++ b/src/entities/bank-card/ui/BankSummary.module.css @@ -0,0 +1,64 @@ +.header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--mantine-spacing-xs) var(--mantine-spacing-lg); +} + +.total { + font-size: var(--mantine-font-size-xl); + font-weight: 650; + letter-spacing: -0.025em; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; + min-width: 0; +} + +.categories { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: repeat(var(--category-rows), auto); + grid-auto-flow: column; + column-gap: var(--mantine-spacing-xl); + margin: 0; +} + +.category { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--mantine-spacing-md); + padding-block: var(--mantine-spacing-sm); + border-top: 1px solid var(--mantine-color-gray-3); + min-width: 0; +} + +.label { + display: flex; + align-items: center; + gap: var(--mantine-spacing-xs); + min-width: 0; + font-size: var(--mantine-font-size-sm); + overflow-wrap: anywhere; +} + +.amount { + flex-shrink: 0; + max-width: 60%; + margin: 0; + font-size: var(--mantine-font-size-sm); + font-weight: 600; + font-variant-numeric: tabular-nums; + text-align: right; + min-width: 0; + overflow-wrap: anywhere; +} + +@media (max-width: 48em) { + .categories { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: none; + grid-auto-flow: row; + } +} diff --git a/src/entities/bank-card/ui/BankSummary.stories.tsx b/src/entities/bank-card/ui/BankSummary.stories.tsx index 3b9387e..d41279d 100644 --- a/src/entities/bank-card/ui/BankSummary.stories.tsx +++ b/src/entities/bank-card/ui/BankSummary.stories.tsx @@ -10,7 +10,7 @@ const meta = { docs: { description: { component: - "Месячные расходы ручного учёта. Вымышленные суммы, без API и сохранения. Возвраты учитываются отдельно; отрицательный итог не превращается в процентную диаграмму.", + "Месячные расходы ручного учёта. Вымышленные суммы, без API и сохранения. Один итог с учётом возвратов и компактные строки категорий. Отрицательные суммы сохраняют знак.", }, }, }, @@ -29,9 +29,30 @@ const meta = { { categoryId: "food", categoryName: "Продукты", - expenses: money("8500"), + expenses: money("3500"), refunds: money("1000"), - net: money("7500"), + net: money("2500"), + }, + { + categoryId: "cafe", + categoryName: "Кафе", + expenses: money("2200"), + refunds: money("0"), + net: money("2200"), + }, + { + categoryId: "taxi", + categoryName: "Такси", + expenses: money("1800"), + refunds: money("0"), + net: money("1800"), + }, + { + categoryId: "health", + categoryName: "Здоровье", + expenses: money("1000"), + refunds: money("0"), + net: money("1000"), }, ], }, @@ -39,7 +60,7 @@ const meta = { } satisfies Meta; export default meta; type Story = StoryObj; -export const Loaded: Story = { name: "Расходы и возвраты" }; +export const Loaded: Story = { name: "Расходы по категориям" }; export const RefundMonth: Story = { name: "Возврат покупки прошлого месяца", args: { @@ -96,7 +117,15 @@ export const LongAmounts: Story = { expenses: money("184467440737095516.14"), refunds: money("0"), net: money("184467440737095516.14"), - categories: [], + categories: [ + { + categoryId: "shopping", + categoryName: "Покупки", + expenses: money("184467440737095516.14"), + refunds: money("0"), + net: money("184467440737095516.14"), + }, + ], }, }, }; diff --git a/src/entities/bank-card/ui/BankSummary.view.tsx b/src/entities/bank-card/ui/BankSummary.view.tsx index 8fd4751..6fc9598 100644 --- a/src/entities/bank-card/ui/BankSummary.view.tsx +++ b/src/entities/bank-card/ui/BankSummary.view.tsx @@ -1,19 +1,11 @@ -import { - Button, - Group, - Paper, - SimpleGrid, - Skeleton, - Stack, - Text, - Title, -} from "@mantine/core"; -import type { FC } from "react"; +import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core"; +import type { CSSProperties, FC } from "react"; import { useTranslation } from "react-i18next"; import { formatMoney } from "@/shared/lib"; import { LoadingSkeleton } from "@/shared/ui"; import type { BankSummary } from "../api/queries"; import { BankOperationIcon } from "./BankOperationIcon"; +import classes from "./BankSummary.module.css"; type BankSummaryViewProps = { data?: BankSummary; @@ -26,71 +18,68 @@ type BankSummaryViewProps = { const BankSummaryView: FC = (props) => { const { t } = useTranslation(); return ( - - + +
{t(props.all ? "bank.allSummary" : "bank.summary")} - {props.data ? ( - <> - - {(["expenses", "refunds", "net"] as const).map((key) => ( -
- {t(`bank.${key}`)} - - {formatMoney(props.data?.[key] ?? null)} - -
- ))} -
- - {t("bank.summaryHint")} - - {props.data.categories.length ? ( - props.data.categories.map((category) => ( - - - - {category.categoryName} - - - {(["expenses", "refunds", "net"] as const).map((key) => ( - - {t(`bank.${key}`)}: {formatMoney(category[key])} - - ))} - - - )) - ) : ( - {t("bank.noExpenses")} - )} - - ) : props.loading ? ( - - - - - - - - ) : null} - {props.error && ( - - {t(props.data ? "bank.refreshError" : "bank.error")} - - + {props.data && ( + + {formatMoney(props.data.net, { withZeroCents: true })} + )} - {props.data && props.fetching && ( - {t("bank.refreshing")} - )} - - +
+ {props.data ? ( + props.data.categories.length ? ( +
+ {props.data.categories.map((category) => ( +
+
+ + {category.categoryName} +
+
+ {formatMoney(category.net, { withZeroCents: true })} +
+
+ ))} +
+ ) : ( + + {t("bank.noExpenses")} + + ) + ) : props.loading ? ( + + + + + + + + ) : null} + {props.error && ( + + {t(props.data ? "bank.refreshError" : "bank.error")} + + + )} + {props.data && props.fetching && ( + {t("bank.refreshing")} + )} +
); }; diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index c8e3442..356684b 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -52,7 +52,7 @@ const ru = { save: "Сохранить", cancel: "Отмена", close: "Закрыть", - summary: "Расходы за месяц", + summary: "Расходы", allSummary: "Расходы по всем картам", month: "Месяц", expenses: "Потрачено", @@ -60,7 +60,7 @@ const ru = { net: "После возвратов", summaryHint: "Возвраты учитываются в месяце получения. Итог может быть отрицательным.", - noExpenses: "Расходов и возвратов за месяц нет.", + noExpenses: "За этот месяц операций по расходам нет.", categories: "Категории", category: "Категория", categoryName: "Название категории", diff --git a/src/shared/lib/display.ts b/src/shared/lib/display.ts index 993b2d1..781c631 100644 --- a/src/shared/lib/display.ts +++ b/src/shared/lib/display.ts @@ -1,4 +1,7 @@ type Money = { currency: string; value: string }; +type FormatMoneyOptions = { + withZeroCents?: boolean; +}; function formatDecimal(value: string): string { const [integer, fraction] = value.split("."); @@ -10,9 +13,21 @@ function formatDecimal(value: string): string { return `${sign}${grouped}${fraction ? `,${fraction}` : ""}`; } -function formatMoney(money: Money | null): string { +function formatMoney( + money: Money | null, + { withZeroCents = false }: FormatMoneyOptions = {}, +): string { if (!money) return "—"; - return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`; + const [integerPart, fractionPart = ""] = money.value.split("."); + const normalizedFraction = + withZeroCents && !fractionPart + ? "00" + : withZeroCents && fractionPart.length === 1 + ? `${fractionPart}0` + : fractionPart; + return `${formatDecimal( + `${integerPart}${normalizedFraction ? `.${normalizedFraction}` : ""}`, + )} ${money.currency === "RUB" ? "₽" : money.currency}`; } function formatDate(value: string): string {