refactor(pwa): штрафы живут только на главном экране

Отдельный список штрафов удалён: два списка одного и того же в разных местах
сбивали с толку. Фильтр «Штрафы» внизу главного экрана теперь показывает сами
постановления, а не проводки по ним, и ведёт в карточку с фотографией.

Разница между постановлением и проводкой здесь принципиальная: штраф, который
водитель оплатил сам, парк не выставлял — проводки нет, и в выписке он бы не
появился вовсе (Морозов, штраф от 16.08). Строка «Долг по штрафам» вверху теперь
включает этот фильтр, а не уводит на другой экран.

Период действует и на штрафы: календарь над списком не должен стоять, ничего не
меняя.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:40:06 +10:00
co-authored by Claude Opus 5
parent 62f8baa80f
commit 0a09570d98
7 changed files with 93 additions and 271 deletions
-2
View File
@@ -12,7 +12,6 @@ import { PaymentPage } from "@/pages/PaymentPage";
import { DevLoginPage } from "@/pages/DevLoginPage";
import { DebugPage } from "@/pages/DebugPage";
import { MockPayPage } from "@/pages/MockPayPage";
import { FinesPage } from "@/pages/FinesPage";
import { FineDetailPage } from "@/pages/FineDetailPage";
function RequireAuth({ children }: { children: ReactNode }) {
@@ -44,7 +43,6 @@ export default function App() {
<Route path="/topup" element={<RequireAuth><TopupPage /></RequireAuth>} />
<Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></RequireAuth>} />
<Route path="/mock-pay/:orderId" element={<RequireAuth><MockPayPage /></RequireAuth>} />
<Route path="/fines" element={<RequireAuth><FinesPage /></RequireAuth>} />
<Route path="/fines/:fineId" element={<RequireAuth><FineDetailPage /></RequireAuth>} />
<Route path="/debug" element={<RequireAuth><RequireStaff><DebugPage /></RequireStaff></RequireAuth>} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -1,7 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { ChevronRight } from "lucide-react";
import { getStatement, type StatementKind } from "@/api/driver";
import { ChevronRight, Image as ImageIcon } from "lucide-react";
import { getFines, getStatement, type StatementKind } from "@/api/driver";
import { fineStatusText, isOwed } from "@/lib/fineStatus";
import { formatDate, formatMoney } from "@/lib/format";
import { OperationIcon } from "@/components/operationIcon";
import { Spinner } from "@/components/Spinner";
@@ -14,10 +15,13 @@ const FILTERS: { key: StatementKind; label: string }[] = [
];
/**
* Выписка по счёту за период — нижняя половина главного экрана.
* Нижняя половина главного экрана: за что списаны деньги.
*
* Это ответ на вопрос «за что списано», который иначе задают звонком в парк.
* Строки те же, по которым 1С считает долг: аренда, пени, штрафы, пополнения.
* Три фильтра показывают проводки 1С, а «Штрафы» — сами постановления, а не
* проводки по ним. Разница существенная: штраф, который водитель оплатил сам,
* парк не выставлял, проводки по нему нет, и в выписке он бы не появился вовсе.
* Отдельного экрана штрафов нет намеренно — два списка одного и того же в разных
* местах сбивают с толку.
*/
export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
from: string;
@@ -28,11 +32,28 @@ export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
onKind: (k: StatementKind) => void;
}) {
const nav = useNavigate();
const { data, isLoading, isError, refetch } = useQuery({
const showFines = kind === "fines";
const ops = useQuery({
queryKey: ["statement", from, to, kind],
queryFn: () => getStatement(from, to, kind),
enabled: !showFines,
});
const items = data?.items ?? [];
const fines = useQuery({
queryKey: ["fines"],
queryFn: () => getFines("all"),
enabled: showFines,
});
const active = showFines ? fines : ops;
// Период распространяется и на штрафы: иначе календарь над списком стоял бы,
// ничего не меняя, — это хуже, чем его отсутствие.
const fineRows = (fines.data?.items ?? []).filter(
(f) => !f.date || (f.date >= from && f.date <= to),
);
const opRows = ops.data?.items ?? [];
const isEmpty = showFines ? fineRows.length === 0 : opRows.length === 0;
const rowCls = "w-full flex items-center gap-3 py-2.5 border-b border-line last:border-0 text-left";
return (
<div>
@@ -53,7 +74,7 @@ export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
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.key === kind ? "border-ink bg-surface font-semibold" : "border-line text-muted"
}`}
>
{f.label}
@@ -61,25 +82,49 @@ export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
))}
</div>
{isLoading ? (
{active.isLoading ? (
<div className="flex justify-center py-8"><Spinner /></div>
) : isError ? (
) : active.isError ? (
<div className="text-center py-6">
<p className="text-muted text-sm mb-3">Не удалось загрузить операции</p>
<button className="btn-ghost" onClick={() => refetch()}>Повторить</button>
<button className="btn-ghost" onClick={() => active.refetch()}>Повторить</button>
</div>
) : isEmpty ? (
<p className="text-muted text-sm text-center py-8">
{showFines ? "Штрафов за период нет" : "Нет операций за выбранный период"}
</p>
) : showFines ? (
<div>
{fineRows.map((f) => (
// Штраф ведёт в карточку: там фотография с камеры, место и скорость.
<button key={f.id} className={rowCls} onClick={() => nav(`/fines/${f.id}`)}>
<OperationIcon title="Штраф" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5 text-xs font-semibold">
{formatDate(f.date)}
{f.has_photo && <ImageIcon size={12} strokeWidth={1.5} className="text-muted" />}
</span>
<span className="block text-sm truncate">{f.article}</span>
<span className="block text-muted text-xs">
{fineStatusText(f)}{f.car_number ? ` · ${f.car_number}` : ""}
</span>
</span>
<b className={`shrink-0 ${isOwed(f) ? "text-neg" : "text-muted"}`}>
{formatMoney(f.amount)}
</b>
<ChevronRight size={16} className="text-muted shrink-0" />
</button>
))}
</div>
) : items.length === 0 ? (
<p className="text-muted text-sm text-center py-8">Нет операций за выбранный период</p>
) : (
<div>
{items.map((op, idx) => {
{opRows.map((op, idx) => {
const row = (
<>
<OperationIcon title={op.title} />
<span className="min-w-0 flex-1">
{/* Дата — первой строкой и чернильным цветом: в выписке ищут
«что было 12-го», а не название операции, и бледная подпись
под заголовком для этого не годится. */}
{/* Дата — первой строкой: в выписке ищут «что было 12-го», а не
название операции. */}
<span className="block text-xs font-semibold">{formatDate(op.date)}</span>
<span className="block text-sm">{op.title}</span>
{op.description && (
@@ -91,17 +136,14 @@ export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
</b>
</>
);
const cls = "w-full flex items-center gap-3 py-2.5 border-b border-line last:border-0 text-left";
// Строка штрафа ведёт в карточку: в выписке видно «Штраф ГИБДД
// −618,75 ₽», а за что именно — только там, вместе с фотографией.
return op.fine_id != null ? (
<button key={`${op.date}-${idx}`} className={cls}
<button key={`${op.date}-${idx}`} className={rowCls}
onClick={() => nav(`/fines/${op.fine_id}`)}>
{row}
<ChevronRight size={16} className="text-muted shrink-0" />
</button>
) : (
<div key={`${op.date}-${idx}`} className={cls}>{row}</div>
<div key={`${op.date}-${idx}`} className={rowCls}>{row}</div>
);
})}
</div>
@@ -16,6 +16,14 @@ vi.mock("@/api/driver", () => ({
synced_at: null,
pending_topup: 1500,
})),
getFines: vi.fn(async () => ({
items: [{
id: 12, number: "18810525260813035846", date: "2026-08-13",
article: "12.09.2 - Превышение скорости", address: "", car_number: "А947УТ125",
amount: 618.75, settled: "owed" as const, cancelled: false, has_photo: true,
}],
open_count: 1, debt: -5156,
})),
getStatement: vi.fn(async () => ({
items: [
{ date: "2026-08-17", title: "Аренда автомобиля", description: "ТС А947УТ125",
@@ -38,7 +46,6 @@ function wrap() {
<Routes>
<Route path="/" element={<BalancePage />} />
<Route path="/topup" element={<p>экран пополнения</p>} />
<Route path="/fines" element={<p>экран штрафов</p>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
@@ -64,9 +71,18 @@ describe("главный экран", () => {
it("фильтр перезапрашивает операции выбранного вида", async () => {
wrap();
await userEvent.click(await screen.findByRole("button", { name: "Штрафы" }));
await userEvent.click(await screen.findByRole("button", { name: "Начисления" }));
const calls = vi.mocked(driver.getStatement).mock.calls;
expect(calls[calls.length - 1][2]).toBe("fines");
expect(calls[calls.length - 1][2]).toBe("charges");
});
it("фильтр «Штрафы» показывает постановления, а не проводки", async () => {
// Штраф, оплаченный водителем самостоятельно, парк не выставлял — проводки по
// нему нет, и в выписке он бы не появился вовсе (Морозов, 18.08.2026).
wrap();
await userEvent.click(await screen.findByRole("button", { name: "Штрафы" }));
expect(await screen.findByText(/Превышение скорости/)).toBeInTheDocument();
expect(driver.getFines).toHaveBeenCalled();
});
it("аренда и штрафы видны сразу, остальные статьи — нет", async () => {
@@ -86,10 +102,11 @@ describe("главный экран", () => {
expect(await screen.findByText("экран пополнения")).toBeInTheDocument();
});
it("строка штрафов открывает список, а не оплату: «за что» важнее кнопки", async () => {
it("строка штрафов включает фильтр внизу, а не уводит на другой экран", async () => {
wrap();
await userEvent.click(await screen.findByRole("button", { name: /Долг по штрафам/ }));
expect(await screen.findByText("экран штрафов")).toBeInTheDocument();
expect(await screen.findByText(/Превышение скорости/)).toBeInTheDocument();
expect(driver.getFines).toHaveBeenCalled();
});
it("категории для выбора живут в шторке «Пополнить»", async () => {
@@ -65,15 +65,16 @@ export function BalancePage() {
{main.length > 0 && (
<div className="mt-3 pt-3 border-t border-line">
{main.map((a) => (
// Аренда ведёт к оплате — за тем её и открывают. Штрафы ведут в свой
// список: «−5 156 ₽» не отвечает на вопрос «за что», а платить,
// не поняв за что, человек не станет.
// Аренда ведёт к оплате — за тем её и открывают. Штрафы включают
// фильтр в списке ниже: «−5 156 ₽» не отвечает на вопрос «за что»,
// а отдельного экрана штрафов нет — два списка одного и того же в
// разных местах сбивали с толку.
<button
key={a.bucket}
disabled={!a.payable}
onClick={() =>
a.bucket === "Долг по штрафам"
? nav("/fines")
? setKind("fines")
: nav("/topup", { state: { bucket: a.bucket } })
}
className="w-full flex justify-between items-baseline py-1.5 text-left disabled:opacity-100"
@@ -38,14 +38,14 @@ export function FineDetailPage() {
<div className="pt-16 text-center">
<p className="text-sm font-semibold mb-1">Штраф не найден</p>
<p className="text-muted text-xs mb-6">Возможно, он больше не числится за вами.</p>
<button className="btn-ghost" onClick={() => nav("/fines")}>К списку штрафов</button>
<button className="btn-ghost" onClick={() => nav("/")}>На главную</button>
</div>
);
}
return (
<div className="pt-2 pb-4">
<button className="text-muted text-xs mb-3" onClick={() => nav("/fines")}> Штрафы</button>
<button className="text-muted text-xs mb-3" onClick={() => nav("/")}> Назад</button>
{f.photos.length > 0 ? (
<div className="mb-4 space-y-2">
@@ -1,131 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Реальный случай: парк уже погасил постановление в ГИБДД со скидкой, а водитель
// парку ещё должен. Именно на нём приложение показывало «неоплаченных нет».
const FINE = {
id: 12, number: "18810525260813035846", date: "2026-08-12",
article: "12.09.2 - Превышение скорости", address: "Владивосток, Некрасовская, 90",
car_number: "А947УТ125", amount: 618.75,
settled: "owed" as const, cancelled: false, has_photo: true,
};
vi.mock("@/api/driver", () => ({
getFines: vi.fn(async () => ({
items: [FINE, { ...FINE, id: 13, settled: "paid" as const, amount: 500 }],
open_count: 1, debt: -5156,
})),
getFine: vi.fn(async () => ({
...FINE, post_date: "2026-08-13", speed: 63, speed_limit: 40, speed_excess: 23,
camera: "АвтоУраган-ВСМ2-М",
photos: ["https://s3.pptaxi.ru/pp-fines/1/offense_photo-0.jpg"],
document: "https://s3.pptaxi.ru/pp-fines/1/decision.pdf",
})),
}));
import { FinesPage } from "./FinesPage";
import { FineDetailPage } from "./FineDetailPage";
import * as driver from "@/api/driver";
function wrap(path = "/fines") {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/fines" element={<FinesPage />} />
<Route path="/fines/:fineId" element={<FineDetailPage />} />
<Route path="/topup" element={<p>экран пополнения</p>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
);
}
describe("список штрафов", () => {
beforeEach(() => vi.clearAllMocks());
it("нет непогашенных — открываем «Все», а не пустой экран", async () => {
// Водитель оплатил штраф сам: долг нулевой, но штрафы у него есть. Раньше
// список открывался на «Не погашены» и выглядел пустым (Морозов, 18.08.2026).
vi.mocked(driver.getFines).mockResolvedValueOnce({
items: [{ ...FINE, settled: "paid" as const }], open_count: 0, debt: 0,
} as never);
wrap();
expect(await screen.findByText("погашен")).toBeInTheDocument();
expect(screen.queryByText(/Непогашенных штрафов нет/)).not.toBeInTheDocument();
});
it("долг берётся из баланса 1С, а не из сумм постановлений", async () => {
wrap();
// Парк гасит ГИБДД авансом: если считать по «оплачен/не оплачен» из
// постановлений, экран покажет «долгов нет» при −5 156 ₽ на главном.
expect(await screen.findByText(/Долг по штрафам −5 156 ₽ · не погашено: 1/)).toBeInTheDocument();
});
it("закрытый в ГИБДД штраф остаётся непогашенным для водителя", async () => {
wrap();
expect(await screen.findByText("не погашен")).toBeInTheDocument();
expect(screen.queryByText("оплачен")).not.toBeInTheDocument();
});
it("вкладка «Все» показывает и погашенные", async () => {
wrap();
await screen.findByText(/Долг по штрафам/);
await userEvent.click(screen.getByRole("button", { name: "Все" }));
expect(await screen.findByText("погашен")).toBeInTheDocument();
});
it("штраф открывается в карточку", async () => {
wrap();
await userEvent.click(await screen.findByRole("button", { name: /Превышение скорости/ }));
expect(await screen.findByRole("heading", { name: /Превышение скорости/ })).toBeInTheDocument();
});
});
describe("карточка штрафа", () => {
beforeEach(() => vi.clearAllMocks());
it("фотография нарушения стоит первой и открывается в полный размер", async () => {
wrap("/fines/12");
const img = await screen.findByAltText("Фото нарушения 1");
expect(img).toHaveAttribute("src", expect.stringContaining("offense_photo"));
expect(img.closest("a")).toHaveAttribute("target", "_blank");
});
it("показывает начисленную сумму, скорость и постановление", async () => {
wrap("/fines/12");
expect(await screen.findByText("618,75 ₽")).toBeInTheDocument();
expect(screen.getByText(/63 км\/ч при разрешённых 40/)).toBeInTheDocument();
expect(screen.getByText("18810525260813035846")).toBeInTheDocument();
});
it("расчёты парка с ГИБДД водителю не показываются", async () => {
// Решение владельца: без номинала, скидки и «парк уже оплатил». В начисление
// заложена комиссия парка, и любая из этих строк рядом требует объяснений.
wrap("/fines/12");
await screen.findByText("618,75 ₽");
expect(screen.queryByText(/750/)).not.toBeInTheDocument();
expect(screen.queryByText(/скидк/i)).not.toBeInTheDocument();
expect(screen.queryByText(/ГИБДД/)).not.toBeInTheDocument();
expect(screen.queryByText(/парк/i)).not.toBeInTheDocument();
});
it("неоплаченный штраф ведёт к оплате штрафов", async () => {
wrap("/fines/12");
await userEvent.click(await screen.findByRole("button", { name: /Оплатить штрафы/ }));
expect(await screen.findByText("экран пополнения")).toBeInTheDocument();
});
it("нет фото — экран объясняет это, а не показывает пустоту", async () => {
vi.mocked(driver.getFine).mockResolvedValueOnce({
...FINE, post_date: null, speed: null, speed_limit: null, speed_excess: null,
camera: "", photos: [], document: null,
} as never);
wrap("/fines/12");
expect(await screen.findByText(/Фотографии по этому постановлению нет/)).toBeInTheDocument();
});
});
-105
View File
@@ -1,105 +0,0 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { ChevronRight, Image as ImageIcon } from "lucide-react";
import { getFines } from "@/api/driver";
import { fineStatusText, isOwed } from "@/lib/fineStatus";
import { formatDate, formatMoney } from "@/lib/format";
import { OperationIcon } from "@/components/operationIcon";
import { Spinner } from "@/components/Spinner";
/**
* Штрафы водителя списком.
*
* Строка «Долг по штрафам −5 156 ₽» на балансе не отвечает на вопрос «за что»,
* и водитель идёт выяснять звонком. Здесь он видит каждое постановление, а в
* карточке — фотографию с камеры.
*
* Долг в шапке берём из баланса 1С, а не суммой строк: постановление парк гасит
* авансом, и по бумагам ГИБДД оно закрыто в тот же день — долг перед парком от
* этого никуда не девается. Список без этой оговорки показывал бы «неоплаченных
* нет» при −5 156 ₽ на главном экране.
*/
export function FinesPage() {
const nav = useNavigate();
// Запрашиваем ВСЕ и фильтруем на месте: список короткий, зато вкладка
// переключается мгновенно и, главное, известно, есть ли непогашенные ещё до
// того, как выбрана вкладка.
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["fines"],
queryFn: () => getFines("all"),
});
const [tab, setTab] = useState<"open" | "all" | null>(null);
// Долгов нет — открываем «Все». Иначе водитель с оплаченными штрафами видел
// пустой экран и решал, что приложение их потеряло (Морозов, 18.08.2026).
const active = tab ?? (data && data.open_count > 0 ? "open" : "all");
const items = (data?.items ?? []).filter((f) => active === "all" || f.settled === "owed");
return (
<div className="pt-2">
<button className="text-muted text-xs mb-3" onClick={() => nav("/")}> Назад</button>
<h1 className="text-lg font-extrabold mb-1">Штрафы</h1>
<p className="text-muted text-xs mb-4">
{data
? data.debt !== 0 || data.open_count > 0
? `Долг по штрафам ${formatMoney(data.debt)} · не погашено: ${data.open_count}`
: "Долгов по штрафам нет"
: " "}
</p>
<div className="flex gap-1.5 mb-3">
{([["open", "Не погашены"], ["all", "Все"]] as const).map(([key, label]) => (
<button
key={key}
onClick={() => setTab(key)}
className={`px-3 py-2 rounded-xl2 text-xs border ${
active === key ? "border-ink bg-surface font-semibold" : "border-line text-muted"
}`}
>
{label}
</button>
))}
</div>
{isLoading ? (
<div className="flex justify-center py-10"><Spinner /></div>
) : isError ? (
<div className="text-center py-8">
<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-10">
{active === "open" ? "Непогашенных штрафов нет" : "Штрафов не найдено"}
</p>
) : (
<div>
{items.map((f) => (
<button
key={f.id}
onClick={() => nav(`/fines/${f.id}`)}
className="w-full flex items-center gap-3 py-3 border-b border-line last:border-0 text-left"
>
<OperationIcon title="Штраф" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5 text-xs font-semibold">
{formatDate(f.date)}
{f.has_photo && <ImageIcon size={12} strokeWidth={1.5} className="text-muted" />}
</span>
<span className="block text-sm truncate">{f.article}</span>
{f.car_number && (
<span className="block text-muted text-xs">{f.car_number}</span>
)}
</span>
<span className="shrink-0 text-right">
<b className={isOwed(f) ? "text-neg" : "text-muted"}>{formatMoney(f.amount)}</b>
<span className="block text-muted text-[11px]">{fineStatusText(f)}</span>
</span>
<ChevronRight size={16} className="text-muted shrink-0" />
</button>
))}
</div>
)}
</div>
);
}