diff --git a/driver-pwa/frontend/src/App.tsx b/driver-pwa/frontend/src/App.tsx
index 78bb616..c497d5d 100644
--- a/driver-pwa/frontend/src/App.tsx
+++ b/driver-pwa/frontend/src/App.tsx
@@ -13,6 +13,8 @@ import { HistoryPage } from "@/pages/HistoryPage";
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 }) {
const token = useAuth((s) => s.token);
@@ -44,6 +46,8 @@ export default function App() {
} />
} />
} />
+ } />
+ } />
} />
} />
diff --git a/driver-pwa/frontend/src/api/driver.ts b/driver-pwa/frontend/src/api/driver.ts
index fe5a880..c46669a 100644
--- a/driver-pwa/frontend/src/api/driver.ts
+++ b/driver-pwa/frontend/src/api/driver.ts
@@ -139,3 +139,42 @@ export const getStatement = async (from: string, to: string, kind: StatementKind
StatementSchema.parse(await api.get("driver/statement", {
searchParams: { from, to, kind },
}).json());
+
+const FineBriefSchema = z.object({
+ id: z.number(),
+ number: z.string(),
+ date: z.string().nullable(),
+ article: z.string(),
+ address: z.string().default(""),
+ car_number: z.string().default(""),
+ amount: z.number(),
+ discount_amount: z.number().default(0),
+ discount_until: z.string().nullable().default(null),
+ paid: z.boolean(),
+ cancelled: z.boolean().default(false),
+ has_photo: z.boolean().default(false),
+});
+export type FineBrief = z.infer;
+
+export const FinesSchema = z.object({
+ items: z.array(FineBriefSchema),
+ open_count: z.number(),
+ open_sum: z.number(),
+});
+
+export const FineDetailSchema = FineBriefSchema.extend({
+ post_date: z.string().nullable().default(null),
+ speed: z.number().nullable().default(null),
+ speed_limit: z.number().nullable().default(null),
+ speed_excess: z.number().nullable().default(null),
+ camera: z.string().default(""),
+ // Ссылки подписанные и живут час — держать их в кэше дольше нет смысла.
+ photos: z.array(z.string()).default([]),
+ document: z.string().nullable().default(null),
+});
+
+export const getFines = async (status: "all" | "open") =>
+ FinesSchema.parse(await api.get("driver/fines", { searchParams: { status } }).json());
+
+export const getFine = async (id: number) =>
+ FineDetailSchema.parse(await api.get(`driver/fines/${id}`).json());
diff --git a/driver-pwa/frontend/src/pages/BalancePage.test.tsx b/driver-pwa/frontend/src/pages/BalancePage.test.tsx
index cbc2685..734189c 100644
--- a/driver-pwa/frontend/src/pages/BalancePage.test.tsx
+++ b/driver-pwa/frontend/src/pages/BalancePage.test.tsx
@@ -38,6 +38,7 @@ function wrap() {
} />
экран пополнения
} />
+ экран штрафов} />
@@ -79,10 +80,16 @@ describe("главный экран", () => {
expect(screen.queryByText("Страховка")).not.toBeInTheDocument();
});
- it("нажатие на строку долга ведёт сразу к оплате этой статьи", async () => {
+ it("аренда ведёт к оплате, а штрафы — в свой список", async () => {
+ wrap();
+ await userEvent.click(await screen.findByRole("button", { name: /Долг аренда/ }));
+ expect(await screen.findByText("экран пополнения")).toBeInTheDocument();
+ });
+
+ it("строка штрафов открывает список, а не оплату: «за что» важнее кнопки", async () => {
wrap();
await userEvent.click(await screen.findByRole("button", { name: /Долг по штрафам/ }));
- expect(await screen.findByText("экран пополнения")).toBeInTheDocument();
+ expect(await screen.findByText("экран штрафов")).toBeInTheDocument();
});
it("категории для выбора живут в шторке «Пополнить»", async () => {
diff --git a/driver-pwa/frontend/src/pages/BalancePage.tsx b/driver-pwa/frontend/src/pages/BalancePage.tsx
index b8b8c31..1b0348b 100644
--- a/driver-pwa/frontend/src/pages/BalancePage.tsx
+++ b/driver-pwa/frontend/src/pages/BalancePage.tsx
@@ -65,12 +65,17 @@ export function BalancePage() {
{main.length > 0 && (
{main.map((a) => (
- // Нажатие ведёт сразу к оплате этой статьи: чаще всего человек за
- // тем и открыл приложение, а лишний заход в шторку — лишний шаг.
+ // Аренда ведёт к оплате — за тем её и открывают. Штрафы ведут в свой
+ // список: «−5 156 ₽» не отвечает на вопрос «за что», а платить,
+ // не поняв за что, человек не станет.
nav("/topup", { state: { bucket: a.bucket } })}
+ onClick={() =>
+ a.bucket === "Долг по штрафам"
+ ? nav("/fines")
+ : nav("/topup", { state: { bucket: a.bucket } })
+ }
className="w-full flex justify-between items-baseline py-1.5 text-left disabled:opacity-100"
>
{a.bucket}
diff --git a/driver-pwa/frontend/src/pages/FineDetailPage.tsx b/driver-pwa/frontend/src/pages/FineDetailPage.tsx
new file mode 100644
index 0000000..ab82b6d
--- /dev/null
+++ b/driver-pwa/frontend/src/pages/FineDetailPage.tsx
@@ -0,0 +1,122 @@
+import { useQuery } from "@tanstack/react-query";
+import { useNavigate, useParams } from "react-router-dom";
+import { FileText, Gauge } from "lucide-react";
+import { getFine } from "@/api/driver";
+import { formatDate, formatMoney } from "@/lib/format";
+import { Spinner } from "@/components/Spinner";
+
+function Row({ k, v }: { k: string; v: string }) {
+ return (
+
+ {k}
+ {v}
+
+ );
+}
+
+/**
+ * Карточка штрафа. Главное здесь — фотография с камеры: по ней водитель узнаёт
+ * свою машину и свою поездку либо понимает, что штраф не его. Поэтому она стоит
+ * первой, до реквизитов, и открывается в полный размер по нажатию.
+ */
+export function FineDetailPage() {
+ const { fineId = "" } = useParams();
+ const nav = useNavigate();
+ const { data: f, isLoading, isError } = useQuery({
+ queryKey: ["fine", fineId],
+ queryFn: () => getFine(Number(fineId)),
+ });
+
+ if (isLoading) return
;
+ if (isError || !f) {
+ return (
+
+
Штраф не найден
+
Возможно, он больше не числится за вами.
+
nav("/fines")}>К списку штрафов
+
+ );
+ }
+
+ const discountLive =
+ f.discount_amount > 0 && !!f.discount_until && !f.paid &&
+ new Date(f.discount_until) >= new Date(new Date().toDateString());
+
+ return (
+
+
nav("/fines")}>‹ Штрафы
+
+ {f.photos.length > 0 ? (
+
+ {f.photos.map((src, i) => (
+
+
+
+ ))}
+
Нажмите на фото, чтобы открыть в полном размере
+
+ ) : (
+
+ Фотографии по этому постановлению нет — её присылают не по всем нарушениям.
+
+ )}
+
+
{f.article}
+
+ {formatDate(f.date)}{f.address ? ` · ${f.address}` : ""}
+
+
+
+
+ {formatMoney(f.amount)}
+
+
+ {f.cancelled ? "Постановление отменено" : f.paid ? "Оплачен" : "Не оплачен"}
+
+ {discountLive && (
+ // Половина суммы в первые 20 дней — то, ради чего в карточку заходят
+ // вовремя. Дату показываем прямо здесь, а не в реквизитах ниже.
+
+ До {formatDate(f.discount_until)} — {formatMoney(f.discount_amount)} со скидкой
+
+ )}
+
+
+ {f.speed != null && f.speed_limit != null && (
+
+
+
+ {f.speed} км/ч при разрешённых {f.speed_limit}
+ {f.speed_excess ? ` — превышение на ${f.speed_excess}` : ""}
+
+
+ )}
+
+
+
+ {f.post_date &&
}
+ {f.car_number &&
}
+ {f.camera &&
}
+
+
+ {f.document && (
+
+ Открыть постановление
+
+ )}
+
+ {!f.paid && !f.cancelled && (
+
nav("/topup", { state: { bucket: "Долг по штрафам" } })}>
+ Оплатить штрафы
+
+ )}
+
+ );
+}
diff --git a/driver-pwa/frontend/src/pages/FinesPage.test.tsx b/driver-pwa/frontend/src/pages/FinesPage.test.tsx
new file mode 100644
index 0000000..8ad287c
--- /dev/null
+++ b/driver-pwa/frontend/src/pages/FinesPage.test.tsx
@@ -0,0 +1,100 @@
+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: 562.5, discount_amount: 281.25,
+ discount_until: "2026-09-01", paid: false, cancelled: false, has_photo: true,
+};
+
+vi.mock("@/api/driver", () => ({
+ getFines: vi.fn(async (status: string) => ({
+ items: status === "open" ? [FINE] : [FINE, { ...FINE, id: 13, paid: true, amount: 500 }],
+ open_count: 1, open_sum: 562.5,
+ })),
+ 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(
+
+
+
+ } />
+ } />
+ экран пополнения} />
+
+
+
+ );
+}
+
+describe("список штрафов", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("открывается на неоплаченных и показывает сумму долга", async () => {
+ wrap();
+ expect(await screen.findByText(/Не оплачено: 1 на 562,50 ₽/)).toBeInTheDocument();
+ expect(vi.mocked(driver.getFines).mock.calls[0][0]).toBe("open");
+ });
+
+ 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(/63 км\/ч при разрешённых 40/)).toBeInTheDocument();
+ expect(screen.getByText(/281,25 ₽ со скидкой/)).toBeInTheDocument();
+ expect(screen.getByText("18810525260813035846")).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();
+ });
+});
diff --git a/driver-pwa/frontend/src/pages/FinesPage.tsx b/driver-pwa/frontend/src/pages/FinesPage.tsx
new file mode 100644
index 0000000..be6330d
--- /dev/null
+++ b/driver-pwa/frontend/src/pages/FinesPage.tsx
@@ -0,0 +1,93 @@
+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 { formatDate, formatMoney } from "@/lib/format";
+import { OperationIcon } from "@/components/operationIcon";
+import { Spinner } from "@/components/Spinner";
+
+/**
+ * Штрафы водителя списком.
+ *
+ * Строка «Долг по штрафам −5 156 ₽» на балансе не отвечает на вопрос «за что»,
+ * и водитель идёт выяснять звонком. Здесь он видит каждое постановление, а в
+ * карточке — фотографию с камеры.
+ */
+export function FinesPage() {
+ const nav = useNavigate();
+ const [tab, setTab] = useState<"open" | "all">("open");
+ const { data, isLoading, isError, refetch } = useQuery({
+ queryKey: ["fines", tab],
+ queryFn: () => getFines(tab),
+ });
+ const items = data?.items ?? [];
+
+ return (
+
+
nav("/")}>‹ Назад
+
Штрафы
+
+ {data
+ ? data.open_count > 0
+ ? `Не оплачено: ${data.open_count} на ${formatMoney(data.open_sum)}`
+ : "Неоплаченных нет"
+ : " "}
+
+
+
+ {([["open", "Неоплаченные"], ["all", "Все"]] as const).map(([key, label]) => (
+ setTab(key)}
+ className={`px-3 py-2 rounded-xl2 text-xs border ${
+ tab === key ? "border-line bg-surface font-semibold" : "border-line text-muted"
+ }`}
+ >
+ {label}
+
+ ))}
+
+
+ {isLoading ? (
+
+ ) : isError ? (
+
+
Не удалось загрузить штрафы
+
refetch()}>Повторить
+
+ ) : items.length === 0 ? (
+
+ {tab === "open" ? "Неоплаченных штрафов нет" : "Штрафов не найдено"}
+
+ ) : (
+
+ {items.map((f) => (
+ nav(`/fines/${f.id}`)}
+ className="w-full flex items-center gap-3 py-3 border-b border-line last:border-0 text-left"
+ >
+
+
+ {f.article}
+
+ {formatDate(f.date)}
+ {f.car_number ? ` · ${f.car_number}` : ""}
+ {f.has_photo && }
+
+
+
+ {formatMoney(f.amount)}
+
+ {f.cancelled ? "отменён" : f.paid ? "оплачен" : "не оплачен"}
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}