From 2d4b66d51fa7f96436d7037b79785506b59615b3 Mon Sep 17 00:00:00 2001 From: vladtechno Date: Tue, 18 Aug 2026 16:35:01 +1000 Subject: [PATCH] =?UTF-8?q?feat(pwa):=20=D0=B3=D0=BB=D0=B0=D0=B2=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=B4=D0=B2=D1=83=D1=85=20=D0=BF=D0=BE=D0=BB=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BD=20=E2=80=94=20=D0=B4=D0=B5=D0=BD=D1=8C=D0=B3=D0=B8=20?= =?UTF-8?q?=D1=81=D0=B2=D0=B5=D1=80=D1=85=D1=83,=20=D0=B2=D1=8B=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=B0=20=D1=81=D0=BD=D0=B8=D0=B7=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По образцу приложения вендора: баланс в парке и «зачисляется», под ними крупная кнопка «Пополнить», ниже — период с календарями, фильтры и список операций (аренда, пени, штрафы, пополнения). Разбивка по статьям переехала в шторку «Пополнить». Она нужна ровно в момент выбора, куда платить; всё остальное время это шесть строк, отодвигающих выписку за нижний край телефона. В шторке только те статьи, в которые платёж проводится. Экран суммы, придя из шторки, показывает выбранную категорию строкой, а не рядом чипов: второй выбор того же самого сбивает с толку. Co-Authored-By: Claude Opus 5 --- driver-pwa/frontend/src/api/driver.ts | 20 +++ driver-pwa/frontend/src/components/Sheet.tsx | 52 +++++++ .../frontend/src/components/Statement.tsx | 90 ++++++++++++ driver-pwa/frontend/src/lib/format.ts | 18 +++ .../frontend/src/pages/BalancePage.test.tsx | 83 +++++++---- driver-pwa/frontend/src/pages/BalancePage.tsx | 135 ++++++++++-------- driver-pwa/frontend/src/pages/TopupPage.tsx | 10 +- 7 files changed, 323 insertions(+), 85 deletions(-) create mode 100644 driver-pwa/frontend/src/components/Sheet.tsx create mode 100644 driver-pwa/frontend/src/components/Statement.tsx diff --git a/driver-pwa/frontend/src/api/driver.ts b/driver-pwa/frontend/src/api/driver.ts index aa4c5f3..fe5a880 100644 --- a/driver-pwa/frontend/src/api/driver.ts +++ b/driver-pwa/frontend/src/api/driver.ts @@ -119,3 +119,23 @@ export const mockPayOutcome = async ( ) => PaymentStateSchema.parse( await api.post(`driver/mock-pay/${orderId}`, { json: { outcome } }).json(), ); + +export const StatementSchema = z.object({ + items: z.array(z.object({ + date: z.string().nullable(), + title: z.string(), + description: z.string().default(""), + amount: z.number(), + kind: z.enum(["charges", "payments", "fines"]), + })), + from: z.string(), + to: z.string(), + kind: z.string(), +}); +export type StatementKind = "all" | "charges" | "payments" | "fines"; + +/** Выписка по счёту за период. Даты — ISO (ГГГГ-ММ-ДД), как их понимает бэкенд. */ +export const getStatement = async (from: string, to: string, kind: StatementKind) => + StatementSchema.parse(await api.get("driver/statement", { + searchParams: { from, to, kind }, + }).json()); diff --git a/driver-pwa/frontend/src/components/Sheet.tsx b/driver-pwa/frontend/src/components/Sheet.tsx new file mode 100644 index 0000000..b3ba178 --- /dev/null +++ b/driver-pwa/frontend/src/components/Sheet.tsx @@ -0,0 +1,52 @@ +import { useEffect, type ReactNode } from "react"; + +/** + * Шторка снизу — выбор, который не уводит с экрана. + * + * Отдельным экраном такой выбор стоит дороже, чем кажется: человек теряет из + * виду то, ради чего пришёл (баланс), и возвращается кнопкой «назад», которой на + * телефоне ещё надо попасть. Здесь выбор происходит поверх экрана, а закрывается + * тапом мимо, кнопкой и клавишей Escape. + */ +export function Sheet({ open, onClose, title, children }: { + open: boolean; + onClose: () => void; + title: string; + children: ReactNode; +}) { + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + document.addEventListener("keydown", onKey); + // Фон не должен уезжать под шторкой: иначе список категорий листает страницу. + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = prev; + }; + }, [open, onClose]); + + if (!open) return null; + + return ( +
+ + ))} +
+ + {isLoading ? ( +
+ ) : isError ? ( +
+

Не удалось загрузить операции

+ +
+ ) : items.length === 0 ? ( +

Нет операций за выбранный период

+ ) : ( +
+ {items.map((op, idx) => ( +
+ + {op.title} + + {formatDate(op.date)} + {op.description ? ` · ${op.description}` : ""} + + + + {formatMoney(op.amount)} + +
+ ))} +
+ )} + + ); +} diff --git a/driver-pwa/frontend/src/lib/format.ts b/driver-pwa/frontend/src/lib/format.ts index 21d17df..81191c5 100644 --- a/driver-pwa/frontend/src/lib/format.ts +++ b/driver-pwa/frontend/src/lib/format.ts @@ -30,3 +30,21 @@ export function formatPhone(national10: string): string { if (d.length > 8) p.push("-" + d.slice(8, 10)); return p.join(""); } + +/** Дата человеку — ДД.ММ.ГГГГ. На проводе и в остаётся ISO. */ +export function formatDate(iso: string | null): string { + if (!iso) return ""; + const [y, m, d] = iso.split("-"); + return y && m && d ? `${d}.${m}.${y}` : iso; +} + +/** ISO-дата на N дней назад от сегодня — для периода выписки по умолчанию. */ +export function isoDaysAgo(days: number): string { + const d = new Date(); + d.setDate(d.getDate() - days); + return d.toISOString().slice(0, 10); +} + +export function isoToday(): string { + return new Date().toISOString().slice(0, 10); +} diff --git a/driver-pwa/frontend/src/pages/BalancePage.test.tsx b/driver-pwa/frontend/src/pages/BalancePage.test.tsx index 0c0fd9e..b6b69b7 100644 --- a/driver-pwa/frontend/src/pages/BalancePage.test.tsx +++ b/driver-pwa/frontend/src/pages/BalancePage.test.tsx @@ -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( @@ -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(); - 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(); }); }); diff --git a/driver-pwa/frontend/src/pages/BalancePage.tsx b/driver-pwa/frontend/src/pages/BalancePage.tsx index a5fdc25..5e62e94 100644 --- a/driver-pwa/frontend/src/pages/BalancePage.tsx +++ b/driver-pwa/frontend/src/pages/BalancePage.tsx @@ -1,92 +1,113 @@ +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; -import { History, LogOut } from "lucide-react"; -import { getBalance } from "@/api/driver"; -import { formatMoney } from "@/lib/format"; +import { ArrowUp, ChevronRight, History, LogOut } from "lucide-react"; +import { getBalance, type StatementKind } from "@/api/driver"; +import { formatMoney, isoDaysAgo, isoToday } from "@/lib/format"; import { useAuth } from "@/store/auth"; import { AppHeader } from "@/components/AppHeader"; +import { Sheet } from "@/components/Sheet"; import { Spinner } from "@/components/Spinner"; +import { Statement } from "@/components/Statement"; +/** + * Главный экран из двух половин: сверху деньги, снизу — за что они списаны. + * + * Разбивка по статьям живёт в шторке «Пополнить», а не на экране: водителю она + * нужна ровно в момент, когда он решает, куда платить, а всё остальное время + * это шесть строк, отодвигающих выписку за нижний край телефона. + */ export function BalancePage() { const nav = useNavigate(); const clear = useAuth((s) => s.clear); const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["balance"], queryFn: getBalance }); + const [sheet, setSheet] = useState(false); + // Неделя — тот же период, что у вендора: столько человек помнит свои поездки. + const [from, setFrom] = useState(isoDaysAgo(7)); + const [to, setTo] = useState(isoToday()); + const [kind, setKind] = useState("all"); const accounts = data?.accounts ?? []; - // Итог — с сервера (это ровно то число, что видит сотрудник в CRM). Сумма строк - // тут не годится: 1С округляет статьи до рублей, а итог держит с копейками. const total = data?.total ?? accounts.reduce((s, a) => s + a.balance, 0); const pending = data?.pending_topup ?? 0; const noData = !isLoading && !isError && accounts.length === 0; + const payable = accounts.filter((a) => a.payable); + + function pick(bucket?: string) { + setSheet(false); + nav("/topup", { state: bucket ? { bucket } : undefined }); + } return (
-
-

Баланс

- {noData ? ( -

Уточняется

- ) : ( -

- {formatMoney(total)} -

- )} + + {/* ── Деньги ─────────────────────────────────────────────── */} +
+
+ Баланс в парке + {isLoading ? ( + + ) : noData ? ( + уточняется + ) : ( + {formatMoney(total)} + )} +
{pending > 0 && ( - // Платёж принят нами, но 1С его ещё не провела. В сумму баланса он не - // входит намеренно: сложить оба источника значило бы показать - // пополнение дважды, как только расчётный лист принесёт тот же платёж. -

- {formatMoney(pending)} зачисляется — учтётся после проведения -

+ // Платёж принят нами, но 1С его ещё не провела. В сумму не входит: сложить + // оба источника значило бы показать пополнение дважды после проведения. +
+ Зачисляется + {formatMoney(pending)} +
+ )} + {isError && ( + )}
- {isLoading ? ( -
- ) : isError ? ( -
-

Не удалось загрузить баланс

- -
- ) : ( -
- {noData && ( -

- Баланс ещё не пришёл из бухгалтерии. Если он нужен прямо сейчас — спросите в парке. -

- )} - {accounts.map((a) => { - const row = ( - <> - {a.bucket} - {formatMoney(a.balance)} - - ); - const cls = "w-full flex justify-between items-center py-3 border-b border-line last:border-0 text-left"; - // В «Страховку» платёж провести нечем — строку показываем, нажатие не - // предлагаем: кнопка, ведущая в отказ, хуже отсутствующей кнопки. - return a.payable ? ( - - ) : ( -
{row}
- ); - })} -
- )} + - + {/* ── За что списано ─────────────────────────────────────── */} +
+ + setSheet(false)} title="Куда пополнить"> + {payable.length === 0 ? ( +

+ Категории пока не загрузились. Попробуйте ещё раз через минуту. +

+ ) : ( + payable.map((a) => ( + + )) + )} +
); } diff --git a/driver-pwa/frontend/src/pages/TopupPage.tsx b/driver-pwa/frontend/src/pages/TopupPage.tsx index 6156a25..e2b2e85 100644 --- a/driver-pwa/frontend/src/pages/TopupPage.tsx +++ b/driver-pwa/frontend/src/pages/TopupPage.tsx @@ -61,7 +61,13 @@ export function TopupPage() {
- {options.length > 1 && ( + {/* Категорию выбирают в шторке на главном экране. Пришли оттуда — показываем + выбранное строкой, а не рядом чипов: второй выбор того же самого сбивает + с толку. Чипы остаются для входа сюда напрямую (кнопка «Пополнить» без + категории, возврат «пополнить ещё раз» с экрана оплаты). */} + {fromBalance ? ( +

Пополняем: {bucket}

+ ) : options.length > 1 ? (
{options.map((b) => (
- )} + ) : null}

{formatMoney(base)}