feat(pwa): главный экран из двух половин — деньги сверху, выписка снизу

По образцу приложения вендора: баланс в парке и «зачисляется», под ними крупная
кнопка «Пополнить», ниже — период с календарями, фильтры и список операций
(аренда, пени, штрафы, пополнения).

Разбивка по статьям переехала в шторку «Пополнить». Она нужна ровно в момент
выбора, куда платить; всё остальное время это шесть строк, отодвигающих выписку
за нижний край телефона. В шторке только те статьи, в которые платёж проводится.

Экран суммы, придя из шторки, показывает выбранную категорию строкой, а не рядом
чипов: второй выбор того же самого сбивает с толку.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 16:35:01 +10:00
co-authored by Claude Opus 5
parent 8b0ce4cf45
commit 2d4b66d51f
7 changed files with 323 additions and 85 deletions
@@ -1,27 +1,37 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Ответ как у настоящего водителя: шесть статей 1С, итог отдельным числом.
// Ответ как у настоящего водителя: статьи 1С, итог отдельным числом.
vi.mock("@/api/driver", () => ({
getBalance: vi.fn(async () => ({
accounts: [
{ bucket: "Долг аренда", balance: -3050, payable: true },
{ bucket: "Долг по штрафам", balance: -5156, payable: true },
{ bucket: "Прочее", balance: -770, payable: true },
{ bucket: "Страховка", balance: 0, payable: false },
{ bucket: "Страховка", balance: 200, payable: false },
],
total: -8976.25,
synced_at: null,
pending_topup: 0,
pending_topup: 1500,
})),
getStatement: vi.fn(async () => ({
items: [
{ date: "2026-08-17", title: "Аренда автомобиля", description: "ТС А947УТ125",
amount: -2600, kind: "charges" },
{ date: "2026-08-17", title: "Пополнение баланса", description: "", amount: 1000,
kind: "payments" },
],
from: "2026-08-11", to: "2026-08-18", kind: "all",
})),
}));
import { BalancePage } from "./BalancePage";
import * as driver from "@/api/driver";
function wrap() {
const qc = new QueryClient();
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter><BalancePage /></MemoryRouter>
@@ -29,32 +39,53 @@ function wrap() {
);
}
describe("BalancePage", () => {
it("итог берётся с сервера, а не складывается из строк", async () => {
describe("главный экран", () => {
it("сверху деньги: итог с сервера и строка «зачисляется»", async () => {
wrap();
expect(await screen.findByText("Долг аренда")).toBeInTheDocument();
// Сумма статей дала бы −8 976, а в CRM у водителя −8 976,25. Показываем её.
expect(screen.getByText("8 976,25 ₽")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Пополнить/i })).toBeInTheDocument();
// Сумма статей дала бы −8 006, а в CRM у водителя −8 976,25. Показываем её.
expect(await screen.findByText("8 976,25 ₽")).toBeInTheDocument();
expect(screen.getByText("Зачисляется")).toBeInTheDocument();
expect(screen.getByText("1 500 ₽")).toBeInTheDocument();
});
it("статья, в которую платить нечем, не притворяется кнопкой", async () => {
it("снизу выписка за период", async () => {
wrap();
await screen.findByText("Страховка");
const buckets = screen.getAllByRole("button").map((b) => b.textContent ?? "");
expect(buckets.some((t) => t.includes("Долг аренда"))).toBe(true);
expect(buckets.some((t) => t.includes("Страховка"))).toBe(false);
expect(await screen.findByText("Аренда автомобиля")).toBeInTheDocument();
expect(screen.getAllByText(/17\.08\.2026/).length).toBe(2); // обе операции того дня
expect(screen.getByRole("button", { name: "Все операции" })).toBeInTheDocument();
expect(driver.getStatement).toHaveBeenCalled();
});
it("shows an error state when balance fails to load", async () => {
const driver = await import("@/api/driver");
(driver.getBalance as any).mockRejectedValueOnce(new Error("boom"));
// re-render fresh
const { QueryClient, QueryClientProvider } = await import("@tanstack/react-query");
const { MemoryRouter } = await import("react-router-dom");
const { render, screen } = await import("@testing-library/react");
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={qc}><MemoryRouter><BalancePage /></MemoryRouter></QueryClientProvider>);
expect(await screen.findByText(/Не удалось загрузить/i)).toBeInTheDocument();
it("фильтр перезапрашивает операции выбранного вида", async () => {
wrap();
await userEvent.click(await screen.findByRole("button", { name: "Штрафы" }));
const calls = vi.mocked(driver.getStatement).mock.calls;
expect(calls[calls.length - 1][2]).toBe("fines");
});
it("категории живут в шторке «Пополнить», а не на экране", async () => {
wrap();
await screen.findByText("8 976,25 ₽");
expect(screen.queryByText("Долг аренда")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /Пополнить/ }));
expect(await screen.findByRole("dialog", { name: "Куда пополнить" })).toBeInTheDocument();
expect(screen.getByText("Долг аренда")).toBeInTheDocument();
// «Страховка» платежом не закрывается — в списке выбора ей делать нечего.
expect(screen.queryByText("Страховка")).not.toBeInTheDocument();
});
it("шторка закрывается тапом мимо", async () => {
wrap();
await screen.findByText("8 976,25 ₽");
await userEvent.click(screen.getByRole("button", { name: /Пополнить/ }));
await userEvent.click(await screen.findByRole("button", { name: "Закрыть" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("баланс не загрузился — экран говорит об этом, а не рисует ноль", async () => {
vi.mocked(driver.getBalance).mockRejectedValueOnce(new Error("boom"));
wrap();
expect(await screen.findByRole("button", { name: /Обновить баланс/ })).toBeInTheDocument();
});
});