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 (
+
+
+
+ {/* Полоска-ручка: на телефоне она объясняет, что панель тянется, лучше любой подписи. */}
+
+
{title}
+ {children}
+
+
+ );
+}
diff --git a/driver-pwa/frontend/src/components/Statement.tsx b/driver-pwa/frontend/src/components/Statement.tsx
new file mode 100644
index 0000000..90f6999
--- /dev/null
+++ b/driver-pwa/frontend/src/components/Statement.tsx
@@ -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 (
+
+
+ {/* Нативный date-input: календарь телефона знакомее любого нашего. */}
+ onFrom(e.target.value)}
+ aria-label="Начало периода"
+ className="card flex-1 px-3 py-2 text-xs bg-white" />
+ —
+ onTo(e.target.value)}
+ aria-label="Конец периода"
+ className="card flex-1 px-3 py-2 text-xs bg-white" />
+
+
+
+ {FILTERS.map((f) => (
+ 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}
+
+ ))}
+
+
+ {isLoading ? (
+
+ ) : isError ? (
+
+
Не удалось загрузить операции
+
refetch()}>Повторить
+
+ ) : 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 && (
+
refetch()}>Обновить баланс
)}
- {isLoading ? (
-
- ) : isError ? (
-
-
Не удалось загрузить баланс
-
refetch()}>Повторить
-
- ) : (
-
- {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 ? (
-
nav("/topup", { state: { bucket: a.bucket } })}>
- {row}
-
- ) : (
-
{row}
- );
- })}
-
- )}
+
setSheet(true)}>
+ Пополнить
+
-
nav("/topup")}>Пополнить
+ {/* ── За что списано ─────────────────────────────────────── */}
+
nav("/history")}>
- История
+ Мои пополнения
{ clear(); nav("/login"); }}>
Выйти
+
+
setSheet(false)} title="Куда пополнить">
+ {payable.length === 0 ? (
+
+ Категории пока не загрузились. Попробуйте ещё раз через минуту.
+
+ ) : (
+ payable.map((a) => (
+ 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"
+ >
+ {a.bucket}
+
+ 0 ? "text-pos" : "text-muted"}>
+ {formatMoney(a.balance)}
+
+
+
+
+ ))
+ )}
+
);
}
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() {
nav(-1)}>‹ Назад
- {options.length > 1 && (
+ {/* Категорию выбирают в шторке на главном экране. Пришли оттуда — показываем
+ выбранное строкой, а не рядом чипов: второй выбор того же самого сбивает
+ с толку. Чипы остаются для входа сюда напрямую (кнопка «Пополнить» без
+ категории, возврат «пополнить ещё раз» с экрана оплаты). */}
+ {fromBalance ? (
+
Пополняем: {bucket}
+ ) : options.length > 1 ? (
{options.map((b) => (
))}
- )}
+ ) : null}
{formatMoney(base)}