From 0a09570d98e7217891aacd1ff9cadd0a48207021 Mon Sep 17 00:00:00 2001 From: vladtechno Date: Tue, 18 Aug 2026 19:40:06 +1000 Subject: [PATCH] =?UTF-8?q?refactor(pwa):=20=D1=88=D1=82=D1=80=D0=B0=D1=84?= =?UTF-8?q?=D1=8B=20=D0=B6=D0=B8=D0=B2=D1=83=D1=82=20=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D0=BD=D0=B0=20=D0=B3=D0=BB=D0=B0=D0=B2?= =?UTF-8?q?=D0=BD=D0=BE=D0=BC=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отдельный список штрафов удалён: два списка одного и того же в разных местах сбивали с толку. Фильтр «Штрафы» внизу главного экрана теперь показывает сами постановления, а не проводки по ним, и ведёт в карточку с фотографией. Разница между постановлением и проводкой здесь принципиальная: штраф, который водитель оплатил сам, парк не выставлял — проводки нет, и в выписке он бы не появился вовсе (Морозов, штраф от 16.08). Строка «Долг по штрафам» вверху теперь включает этот фильтр, а не уводит на другой экран. Период действует и на штрафы: календарь над списком не должен стоять, ничего не меняя. Co-Authored-By: Claude Opus 5 --- driver-pwa/frontend/src/App.tsx | 2 - .../frontend/src/components/Statement.tsx | 86 +++++++++--- .../frontend/src/pages/BalancePage.test.tsx | 27 +++- driver-pwa/frontend/src/pages/BalancePage.tsx | 9 +- .../frontend/src/pages/FineDetailPage.tsx | 4 +- .../frontend/src/pages/FinesPage.test.tsx | 131 ------------------ driver-pwa/frontend/src/pages/FinesPage.tsx | 105 -------------- 7 files changed, 93 insertions(+), 271 deletions(-) delete mode 100644 driver-pwa/frontend/src/pages/FinesPage.test.tsx delete mode 100644 driver-pwa/frontend/src/pages/FinesPage.tsx diff --git a/driver-pwa/frontend/src/App.tsx b/driver-pwa/frontend/src/App.tsx index ea88670..9426bb0 100644 --- a/driver-pwa/frontend/src/App.tsx +++ b/driver-pwa/frontend/src/App.tsx @@ -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() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/driver-pwa/frontend/src/components/Statement.tsx b/driver-pwa/frontend/src/components/Statement.tsx index 041a706..94f2a20 100644 --- a/driver-pwa/frontend/src/components/Statement.tsx +++ b/driver-pwa/frontend/src/components/Statement.tsx @@ -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 (
@@ -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 }: { ))}
- {isLoading ? ( + {active.isLoading ? (
- ) : isError ? ( + ) : active.isError ? (

Не удалось загрузить операции

- + +
+ ) : isEmpty ? ( +

+ {showFines ? "Штрафов за период нет" : "Нет операций за выбранный период"} +

+ ) : showFines ? ( +
+ {fineRows.map((f) => ( + // Штраф ведёт в карточку: там фотография с камеры, место и скорость. + + ))}
- ) : items.length === 0 ? ( -

Нет операций за выбранный период

) : (
- {items.map((op, idx) => { + {opRows.map((op, idx) => { const row = ( <> - {/* Дата — первой строкой и чернильным цветом: в выписке ищут - «что было 12-го», а не название операции, и бледная подпись - под заголовком для этого не годится. */} + {/* Дата — первой строкой: в выписке ищут «что было 12-го», а не + название операции. */} {formatDate(op.date)} {op.title} {op.description && ( @@ -91,17 +136,14 @@ export function Statement({ from, to, kind, onFrom, onTo, onKind }: { ); - 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 ? ( - ) : ( -
{row}
+
{row}
); })}
diff --git a/driver-pwa/frontend/src/pages/BalancePage.test.tsx b/driver-pwa/frontend/src/pages/BalancePage.test.tsx index 69a8cce..413377b 100644 --- a/driver-pwa/frontend/src/pages/BalancePage.test.tsx +++ b/driver-pwa/frontend/src/pages/BalancePage.test.tsx @@ -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() { } /> экран пополнения

} /> - экран штрафов

} />
@@ -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 () => { diff --git a/driver-pwa/frontend/src/pages/BalancePage.tsx b/driver-pwa/frontend/src/pages/BalancePage.tsx index 4991f1e..6226a0f 100644 --- a/driver-pwa/frontend/src/pages/BalancePage.tsx +++ b/driver-pwa/frontend/src/pages/BalancePage.tsx @@ -65,15 +65,16 @@ export function BalancePage() { {main.length > 0 && (
{main.map((a) => ( - // Аренда ведёт к оплате — за тем её и открывают. Штрафы ведут в свой - // список: «−5 156 ₽» не отвечает на вопрос «за что», а платить, - // не поняв за что, человек не станет. + // Аренда ведёт к оплате — за тем её и открывают. Штрафы включают + // фильтр в списке ниже: «−5 156 ₽» не отвечает на вопрос «за что», + // а отдельного экрана штрафов нет — два списка одного и того же в + // разных местах сбивали с толку. +
); } return (
- + {f.photos.length > 0 ? (
diff --git a/driver-pwa/frontend/src/pages/FinesPage.test.tsx b/driver-pwa/frontend/src/pages/FinesPage.test.tsx deleted file mode 100644 index 5397502..0000000 --- a/driver-pwa/frontend/src/pages/FinesPage.test.tsx +++ /dev/null @@ -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( - - - - } /> - } /> - экран пополнения

} /> -
-
-
- ); -} - -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(); - }); -}); diff --git a/driver-pwa/frontend/src/pages/FinesPage.tsx b/driver-pwa/frontend/src/pages/FinesPage.tsx deleted file mode 100644 index 37e0219..0000000 --- a/driver-pwa/frontend/src/pages/FinesPage.tsx +++ /dev/null @@ -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 ( -
- -

Штрафы

-

- {data - ? data.debt !== 0 || data.open_count > 0 - ? `Долг по штрафам ${formatMoney(data.debt)} · не погашено: ${data.open_count}` - : "Долгов по штрафам нет" - : " "} -

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

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

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

- {active === "open" ? "Непогашенных штрафов нет" : "Штрафов не найдено"} -

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