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
+20
View File
@@ -119,3 +119,23 @@ export const mockPayOutcome = async (
) => PaymentStateSchema.parse( ) => PaymentStateSchema.parse(
await api.post(`driver/mock-pay/${orderId}`, { json: { outcome } }).json(), 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());
@@ -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 (
<div className="fixed inset-0 z-50 flex items-end justify-center">
<button
aria-label="Закрыть"
className="absolute inset-0 bg-ink/40"
onClick={onClose}
/>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="relative w-full max-w-md bg-surface rounded-t-2xl pb-6 pt-2 max-h-[80vh] overflow-y-auto"
>
{/* Полоска-ручка: на телефоне она объясняет, что панель тянется, лучше любой подписи. */}
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-line" />
<p className="px-5 pb-2 text-sm font-bold">{title}</p>
{children}
</div>
</div>
);
}
@@ -0,0 +1,90 @@
import { useQuery } from "@tanstack/react-query";
import { getStatement, type StatementKind } from "@/api/driver";
import { formatDate, formatMoney } from "@/lib/format";
import { Spinner } from "@/components/Spinner";
const FILTERS: { key: StatementKind; label: string }[] = [
{ key: "all", label: "Все операции" },
{ key: "charges", label: "Начисления" },
{ key: "payments", label: "Пополнения" },
{ key: "fines", label: "Штрафы" },
];
/**
* Выписка по счёту за период — нижняя половина главного экрана.
*
* Это ответ на вопрос «за что списано», который иначе задают звонком в парк.
* Строки те же, по которым 1С считает долг: аренда, пени, штрафы, пополнения.
*/
export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
from: string;
to: string;
kind: StatementKind;
onFrom: (v: string) => void;
onTo: (v: string) => void;
onKind: (k: StatementKind) => void;
}) {
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["statement", from, to, kind],
queryFn: () => getStatement(from, to, kind),
});
const items = data?.items ?? [];
return (
<div>
<div className="flex items-center gap-2 mb-3">
{/* Нативный date-input: календарь телефона знакомее любого нашего. */}
<input type="date" value={from} max={to} onChange={(e) => onFrom(e.target.value)}
aria-label="Начало периода"
className="card flex-1 px-3 py-2 text-xs bg-white" />
<span className="text-muted text-xs"></span>
<input type="date" value={to} min={from} onChange={(e) => onTo(e.target.value)}
aria-label="Конец периода"
className="card flex-1 px-3 py-2 text-xs bg-white" />
</div>
<div className="flex gap-1.5 mb-3 overflow-x-auto">
{FILTERS.map((f) => (
<button
key={f.key}
onClick={() => onKind(f.key)}
className={`shrink-0 px-3 py-2 rounded-xl2 text-xs border ${
f.key === kind ? "border-line bg-surface font-semibold" : "border-line text-muted"
}`}
>
{f.label}
</button>
))}
</div>
{isLoading ? (
<div className="flex justify-center py-8"><Spinner /></div>
) : isError ? (
<div className="text-center py-6">
<p className="text-muted text-sm mb-3">Не удалось загрузить операции</p>
<button className="btn-ghost" onClick={() => refetch()}>Повторить</button>
</div>
) : items.length === 0 ? (
<p className="text-muted text-sm text-center py-8">Нет операций за выбранный период</p>
) : (
<div>
{items.map((op, idx) => (
<div key={`${op.date}-${idx}`}
className="flex justify-between items-start gap-3 py-3 border-b border-line last:border-0">
<span className="min-w-0">
<span className="block text-sm">{op.title}</span>
<span className="block text-muted text-xs">
{formatDate(op.date)}
{op.description ? ` · ${op.description}` : ""}
</span>
</span>
<b className={`shrink-0 ${op.amount < 0 ? "text-neg" : "text-pos"}`}>
{formatMoney(op.amount)}
</b>
</div>
))}
</div>
)}
</div>
);
}
+18
View File
@@ -30,3 +30,21 @@ export function formatPhone(national10: string): string {
if (d.length > 8) p.push("-" + d.slice(8, 10)); if (d.length > 8) p.push("-" + d.slice(8, 10));
return p.join(""); return p.join("");
} }
/** Дата человеку — ДД.ММ.ГГГГ. На проводе и в <input type="date"> остаётся 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);
}
@@ -1,27 +1,37 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Ответ как у настоящего водителя: шесть статей 1С, итог отдельным числом. // Ответ как у настоящего водителя: статьи 1С, итог отдельным числом.
vi.mock("@/api/driver", () => ({ vi.mock("@/api/driver", () => ({
getBalance: vi.fn(async () => ({ getBalance: vi.fn(async () => ({
accounts: [ accounts: [
{ bucket: "Долг аренда", balance: -3050, payable: true }, { bucket: "Долг аренда", balance: -3050, payable: true },
{ bucket: "Долг по штрафам", balance: -5156, payable: true }, { bucket: "Долг по штрафам", balance: -5156, payable: true },
{ bucket: "Прочее", balance: -770, payable: true }, { bucket: "Страховка", balance: 200, payable: false },
{ bucket: "Страховка", balance: 0, payable: false },
], ],
total: -8976.25, total: -8976.25,
synced_at: null, 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 { BalancePage } from "./BalancePage";
import * as driver from "@/api/driver";
function wrap() { function wrap() {
const qc = new QueryClient(); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render( return render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<MemoryRouter><BalancePage /></MemoryRouter> <MemoryRouter><BalancePage /></MemoryRouter>
@@ -29,32 +39,53 @@ function wrap() {
); );
} }
describe("BalancePage", () => { describe("главный экран", () => {
it("итог берётся с сервера, а не складывается из строк", async () => { it("сверху деньги: итог с сервера и строка «зачисляется»", async () => {
wrap(); wrap();
expect(await screen.findByText("Долг аренда")).toBeInTheDocument(); // Сумма статей дала бы −8 006, а в CRM у водителя −8 976,25. Показываем её.
// Сумма статей дала бы −8 976, а в CRM у водителя −8 976,25. Показываем её. expect(await screen.findByText("8 976,25 ₽")).toBeInTheDocument();
expect(screen.getByText("8 976,25 ₽")).toBeInTheDocument(); expect(screen.getByText("Зачисляется")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Пополнить/i })).toBeInTheDocument(); expect(screen.getByText("1 500 ₽")).toBeInTheDocument();
}); });
it("статья, в которую платить нечем, не притворяется кнопкой", async () => { it("снизу выписка за период", async () => {
wrap(); wrap();
await screen.findByText("Страховка"); expect(await screen.findByText("Аренда автомобиля")).toBeInTheDocument();
const buckets = screen.getAllByRole("button").map((b) => b.textContent ?? ""); expect(screen.getAllByText(/17\.08\.2026/).length).toBe(2); // обе операции того дня
expect(buckets.some((t) => t.includes("Долг аренда"))).toBe(true); expect(screen.getByRole("button", { name: "Все операции" })).toBeInTheDocument();
expect(buckets.some((t) => t.includes("Страховка"))).toBe(false); expect(driver.getStatement).toHaveBeenCalled();
}); });
it("shows an error state when balance fails to load", async () => { it("фильтр перезапрашивает операции выбранного вида", async () => {
const driver = await import("@/api/driver"); wrap();
(driver.getBalance as any).mockRejectedValueOnce(new Error("boom")); await userEvent.click(await screen.findByRole("button", { name: "Штрафы" }));
// re-render fresh const calls = vi.mocked(driver.getStatement).mock.calls;
const { QueryClient, QueryClientProvider } = await import("@tanstack/react-query"); expect(calls[calls.length - 1][2]).toBe("fines");
const { MemoryRouter } = await import("react-router-dom"); });
const { render, screen } = await import("@testing-library/react");
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); it("категории живут в шторке «Пополнить», а не на экране", async () => {
render(<QueryClientProvider client={qc}><MemoryRouter><BalancePage /></MemoryRouter></QueryClientProvider>); wrap();
expect(await screen.findByText(/Не удалось загрузить/i)).toBeInTheDocument(); 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();
}); });
}); });
+76 -55
View File
@@ -1,92 +1,113 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { History, LogOut } from "lucide-react"; import { ArrowUp, ChevronRight, History, LogOut } from "lucide-react";
import { getBalance } from "@/api/driver"; import { getBalance, type StatementKind } from "@/api/driver";
import { formatMoney } from "@/lib/format"; import { formatMoney, isoDaysAgo, isoToday } from "@/lib/format";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
import { AppHeader } from "@/components/AppHeader"; import { AppHeader } from "@/components/AppHeader";
import { Sheet } from "@/components/Sheet";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
import { Statement } from "@/components/Statement";
/**
* Главный экран из двух половин: сверху деньги, снизу — за что они списаны.
*
* Разбивка по статьям живёт в шторке «Пополнить», а не на экране: водителю она
* нужна ровно в момент, когда он решает, куда платить, а всё остальное время
* это шесть строк, отодвигающих выписку за нижний край телефона.
*/
export function BalancePage() { export function BalancePage() {
const nav = useNavigate(); const nav = useNavigate();
const clear = useAuth((s) => s.clear); const clear = useAuth((s) => s.clear);
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["balance"], queryFn: getBalance }); 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<StatementKind>("all");
const accounts = data?.accounts ?? []; const accounts = data?.accounts ?? [];
// Итог — с сервера (это ровно то число, что видит сотрудник в CRM). Сумма строк
// тут не годится: 1С округляет статьи до рублей, а итог держит с копейками.
const total = data?.total ?? accounts.reduce((s, a) => s + a.balance, 0); const total = data?.total ?? accounts.reduce((s, a) => s + a.balance, 0);
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);
function pick(bucket?: string) {
setSheet(false);
nav("/topup", { state: bucket ? { bucket } : undefined });
}
return ( return (
<div> <div>
<AppHeader /> <AppHeader />
<div className="card p-4 mb-4 text-center">
<p className="text-muted text-xs mb-1">Баланс</p>
{noData ? (
<p className="text-sm font-semibold">Уточняется</p>
) : (
<p className={`text-2xl font-extrabold ${total < 0 ? "text-neg" : "text-pos"}`}>
{formatMoney(total)}
</p>
)}
{pending > 0 && (
// Платёж принят нами, но 1С его ещё не провела. В сумму баланса он не
// входит намеренно: сложить оба источника значило бы показать
// пополнение дважды, как только расчётный лист принесёт тот же платёж.
<p className="text-muted text-xs mt-2">
{formatMoney(pending)} зачисляется учтётся после проведения
</p>
)}
</div>
{/* ── Деньги ─────────────────────────────────────────────── */}
<div className="card p-4 mb-3">
<div className="flex justify-between items-baseline">
<span className="text-muted text-xs">Баланс в парке</span>
{isLoading ? ( {isLoading ? (
<div className="flex justify-center py-10"><Spinner /></div> <Spinner />
) : isError ? ( ) : noData ? (
<div className="text-center py-8"> <span className="text-sm font-semibold">уточняется</span>
<p className="text-muted text-sm mb-3">Не удалось загрузить баланс</p>
<button className="btn-ghost" onClick={() => refetch()}>Повторить</button>
</div>
) : ( ) : (
<div className="mb-4"> <b className={`text-2xl ${total < 0 ? "text-neg" : "text-pos"}`}>{formatMoney(total)}</b>
{noData && (
<p className="text-muted text-sm text-center py-6">
Баланс ещё не пришёл из бухгалтерии. Если он нужен прямо сейчас спросите в парке.
</p>
)} )}
{accounts.map((a) => { </div>
const row = ( {pending > 0 && (
<> // Платёж принят нами, но 1С его ещё не провела. В сумму не входит: сложить
<span className="text-sm">{a.bucket}</span> // оба источника значило бы показать пополнение дважды после проведения.
<b className={a.balance < 0 ? "text-neg" : "text-pos"}>{formatMoney(a.balance)}</b> <div className="flex justify-between items-baseline mt-2">
</> <span className="text-muted text-xs">Зачисляется</span>
); <span className="text-sm">{formatMoney(pending)}</span>
const cls = "w-full flex justify-between items-center py-3 border-b border-line last:border-0 text-left";
// В «Страховку» платёж провести нечем — строку показываем, нажатие не
// предлагаем: кнопка, ведущая в отказ, хуже отсутствующей кнопки.
return a.payable ? (
<button key={a.bucket} className={cls}
onClick={() => nav("/topup", { state: { bucket: a.bucket } })}>
{row}
</button>
) : (
<div key={a.bucket} className={cls}>{row}</div>
);
})}
</div> </div>
)} )}
{isError && (
<button className="btn-ghost mt-3" onClick={() => refetch()}>Обновить баланс</button>
)}
</div>
<button className="btn-primary" onClick={() => nav("/topup")}>Пополнить</button> <button className="btn-primary flex items-center justify-center gap-2 mb-5"
onClick={() => setSheet(true)}>
<ArrowUp size={18} /> Пополнить
</button>
{/* ── За что списано ─────────────────────────────────────── */}
<Statement from={from} to={to} kind={kind}
onFrom={setFrom} onTo={setTo} onKind={setKind} />
<div className="flex justify-center gap-6 mt-6 text-muted text-xs"> <div className="flex justify-center gap-6 mt-6 text-muted text-xs">
<button className="flex items-center gap-1.5" onClick={() => nav("/history")}> <button className="flex items-center gap-1.5" onClick={() => nav("/history")}>
<History size={15} /> История <History size={15} /> Мои пополнения
</button> </button>
<button className="flex items-center gap-1.5" onClick={() => { clear(); nav("/login"); }}> <button className="flex items-center gap-1.5" onClick={() => { clear(); nav("/login"); }}>
<LogOut size={15} /> Выйти <LogOut size={15} /> Выйти
</button> </button>
</div> </div>
<Sheet open={sheet} onClose={() => setSheet(false)} title="Куда пополнить">
{payable.length === 0 ? (
<p className="text-muted text-sm px-5 py-4">
Категории пока не загрузились. Попробуйте ещё раз через минуту.
</p>
) : (
payable.map((a) => (
<button
key={a.bucket}
onClick={() => pick(a.bucket)}
className="w-full flex justify-between items-center gap-3 px-5 py-3.5 border-b border-line last:border-0 text-left"
>
<span className="text-sm">{a.bucket}</span>
<span className="flex items-center gap-2 shrink-0">
<b className={a.balance < 0 ? "text-neg" : a.balance > 0 ? "text-pos" : "text-muted"}>
{formatMoney(a.balance)}
</b>
<ChevronRight size={16} className="text-muted" />
</span>
</button>
))
)}
</Sheet>
</div> </div>
); );
} }
+8 -2
View File
@@ -61,7 +61,13 @@ export function TopupPage() {
<div className="pt-2"> <div className="pt-2">
<button className="text-muted text-xs mb-3" onClick={() => nav(-1)}> Назад</button> <button className="text-muted text-xs mb-3" onClick={() => nav(-1)}> Назад</button>
{options.length > 1 && ( {/* Категорию выбирают в шторке на главном экране. Пришли оттуда — показываем
выбранное строкой, а не рядом чипов: второй выбор того же самого сбивает
с толку. Чипы остаются для входа сюда напрямую (кнопка «Пополнить» без
категории, возврат «пополнить ещё раз» с экрана оплаты). */}
{fromBalance ? (
<p className="text-muted text-xs mb-3">Пополняем: <b className="text-ink">{bucket}</b></p>
) : options.length > 1 ? (
<div className="flex gap-1.5 mb-3 overflow-x-auto"> <div className="flex gap-1.5 mb-3 overflow-x-auto">
{options.map((b) => ( {options.map((b) => (
<button <button
@@ -75,7 +81,7 @@ export function TopupPage() {
</button> </button>
))} ))}
</div> </div>
)} ) : null}
<p data-testid="amount" className="text-3xl font-extrabold text-center mt-2">{formatMoney(base)}</p> <p data-testid="amount" className="text-3xl font-extrabold text-center mt-2">{formatMoney(base)}</p>
<p data-testid="total" className="text-muted text-xs text-center mb-3"> <p data-testid="total" className="text-muted text-xs text-center mb-3">