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 ₽» не отвечает на вопрос «за что», а платить, + // не поняв за что, человек не станет. +
+ ); + } + + const discountLive = + f.discount_amount > 0 && !!f.discount_until && !f.paid && + new Date(f.discount_until) >= new Date(new Date().toDateString()); + + return ( +
+ + + {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 && ( + + )} +
+ ); +} 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 ( +
+ +

Штрафы

+

+ {data + ? data.open_count > 0 + ? `Не оплачено: ${data.open_count} на ${formatMoney(data.open_sum)}` + : "Неоплаченных нет" + : " "} +

+ +
+ {([["open", "Неоплаченные"], ["all", "Все"]] as const).map(([key, label]) => ( + + ))} +
+ + {isLoading ? ( +
+ ) : isError ? ( +
+

Не удалось загрузить штрафы

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

+ {tab === "open" ? "Неоплаченных штрафов нет" : "Штрафов не найдено"} +

+ ) : ( +
+ {items.map((f) => ( + + ))} +
+ )} +
+ ); +}