feat(pwa): аренда и штрафы видны прямо на главном экране
Две статьи, которые водитель проверяет каждый день: за них он платит и из-за них ему звонят. Стоят под итогом, нажатие ведёт сразу к оплате этой статьи — чаще всего человек за тем и открыл приложение, а заход в шторку это лишний шаг. Остальные статьи (депозит, повреждения, прочее) на экран не выносим: они редкие, им хватает шторки, иначе экран снова превращается в столбик из шести строк и выписка уезжает за нижний край телефона. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
// Ответ как у настоящего водителя: статьи 1С, итог отдельным числом.
|
// Ответ как у настоящего водителя: статьи 1С, итог отдельным числом.
|
||||||
@@ -34,7 +34,12 @@ function wrap() {
|
|||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<MemoryRouter><BalancePage /></MemoryRouter>
|
<MemoryRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<BalancePage />} />
|
||||||
|
<Route path="/topup" element={<p>экран пополнения</p>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -63,22 +68,39 @@ describe("главный экран", () => {
|
|||||||
expect(calls[calls.length - 1][2]).toBe("fines");
|
expect(calls[calls.length - 1][2]).toBe("fines");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("категории живут в шторке «Пополнить», а не на экране", async () => {
|
it("аренда и штрафы видны сразу, остальные статьи — нет", async () => {
|
||||||
wrap();
|
wrap();
|
||||||
await screen.findByText("−8 976,25 ₽");
|
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.getByText("Долг аренда")).toBeInTheDocument();
|
||||||
// «Страховка» платежом не закрывается — в списке выбора ей делать нечего.
|
expect(screen.getByText("Долг по штрафам")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("−3 050 ₽")).toBeInTheDocument();
|
||||||
|
// Редкие статьи на экран не лезут: для них есть шторка.
|
||||||
expect(screen.queryByText("Страховка")).not.toBeInTheDocument();
|
expect(screen.queryByText("Страховка")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("нажатие на строку долга ведёт сразу к оплате этой статьи", async () => {
|
||||||
|
wrap();
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: /Долг по штрафам/ }));
|
||||||
|
expect(await screen.findByText("экран пополнения")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("категории для выбора живут в шторке «Пополнить»", async () => {
|
||||||
|
wrap();
|
||||||
|
await screen.findByText("−8 976,25 ₽");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /^Пополнить$/ }));
|
||||||
|
const sheet = await screen.findByRole("dialog", { name: "Куда пополнить" });
|
||||||
|
const inSheet = within(sheet);
|
||||||
|
expect(inSheet.getByText("Долг аренда")).toBeInTheDocument();
|
||||||
|
expect(inSheet.getByText("Долг по штрафам")).toBeInTheDocument();
|
||||||
|
// «Страховка» платежом не закрывается — в списке выбора ей делать нечего.
|
||||||
|
expect(inSheet.queryByText("Страховка")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("шторка закрывается тапом мимо", async () => {
|
it("шторка закрывается тапом мимо", async () => {
|
||||||
wrap();
|
wrap();
|
||||||
await screen.findByText("−8 976,25 ₽");
|
await screen.findByText("−8 976,25 ₽");
|
||||||
await userEvent.click(screen.getByRole("button", { name: /Пополнить/ }));
|
await userEvent.click(screen.getByRole("button", { name: /^Пополнить$/ }));
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Закрыть" }));
|
await userEvent.click(await screen.findByRole("button", { name: "Закрыть" }));
|
||||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import { Sheet } from "@/components/Sheet";
|
|||||||
import { Spinner } from "@/components/Spinner";
|
import { Spinner } from "@/components/Spinner";
|
||||||
import { Statement } from "@/components/Statement";
|
import { Statement } from "@/components/Statement";
|
||||||
|
|
||||||
|
// Две статьи, которые водитель проверяет каждый день: за них он платит и из-за
|
||||||
|
// них ему звонят. Остальные (депозит, повреждения, прочее) — редкие, им хватает
|
||||||
|
// шторки «Пополнить», иначе экран снова превращается в столбик из шести строк.
|
||||||
|
const MAIN_BUCKETS = ["Долг аренда", "Долг по штрафам"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Главный экран из двух половин: сверху деньги, снизу — за что они списаны.
|
* Главный экран из двух половин: сверху деньги, снизу — за что они списаны.
|
||||||
*
|
*
|
||||||
@@ -32,6 +37,9 @@ export function BalancePage() {
|
|||||||
const pending = data?.pending_topup ?? 0;
|
const pending = data?.pending_topup ?? 0;
|
||||||
const noData = !isLoading && !isError && accounts.length === 0;
|
const noData = !isLoading && !isError && accounts.length === 0;
|
||||||
const payable = accounts.filter((a) => a.payable);
|
const payable = accounts.filter((a) => a.payable);
|
||||||
|
const main = MAIN_BUCKETS.map((b) => accounts.find((a) => a.bucket === b)).filter(
|
||||||
|
(a): a is NonNullable<typeof a> => a != null,
|
||||||
|
);
|
||||||
|
|
||||||
function pick(bucket?: string) {
|
function pick(bucket?: string) {
|
||||||
setSheet(false);
|
setSheet(false);
|
||||||
@@ -54,10 +62,29 @@ export function BalancePage() {
|
|||||||
<b className={`text-2xl ${total < 0 ? "text-neg" : "text-pos"}`}>{formatMoney(total)}</b>
|
<b className={`text-2xl ${total < 0 ? "text-neg" : "text-pos"}`}>{formatMoney(total)}</b>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{main.length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-line">
|
||||||
|
{main.map((a) => (
|
||||||
|
// Нажатие ведёт сразу к оплате этой статьи: чаще всего человек за
|
||||||
|
// тем и открыл приложение, а лишний заход в шторку — лишний шаг.
|
||||||
|
<button
|
||||||
|
key={a.bucket}
|
||||||
|
disabled={!a.payable}
|
||||||
|
onClick={() => nav("/topup", { state: { bucket: a.bucket } })}
|
||||||
|
className="w-full flex justify-between items-baseline py-1.5 text-left disabled:opacity-100"
|
||||||
|
>
|
||||||
|
<span className="text-muted text-xs">{a.bucket}</span>
|
||||||
|
<span className={`text-sm ${a.balance < 0 ? "text-neg" : a.balance > 0 ? "text-pos" : ""}`}>
|
||||||
|
{formatMoney(a.balance)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{pending > 0 && (
|
{pending > 0 && (
|
||||||
// Платёж принят нами, но 1С его ещё не провела. В сумму не входит: сложить
|
// Платёж принят нами, но 1С его ещё не провела. В сумму не входит: сложить
|
||||||
// оба источника значило бы показать пополнение дважды после проведения.
|
// оба источника значило бы показать пополнение дважды после проведения.
|
||||||
<div className="flex justify-between items-baseline mt-2">
|
<div className="flex justify-between items-baseline mt-3 pt-3 border-t border-line">
|
||||||
<span className="text-muted text-xs">Зачисляется</span>
|
<span className="text-muted text-xs">Зачисляется</span>
|
||||||
<span className="text-sm">{formatMoney(pending)}</span>
|
<span className="text-sm">{formatMoney(pending)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user