From f4a9da25cf3c894ba85f0bf966f9fb1bc99fe044 Mon Sep 17 00:00:00 2001
From: vladtechno
Date: Tue, 18 Aug 2026 17:18:58 +1000
Subject: [PATCH] =?UTF-8?q?feat(pwa):=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20?=
=?UTF-8?q?=D1=88=D1=82=D1=80=D0=B0=D1=84=D0=BE=D0=B2=20=D1=81=20=D1=84?=
=?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D1=8F=D0=BC?=
=?UTF-8?q?=D0=B8=20=D0=BD=D0=B0=D1=80=D1=83=D1=88=D0=B5=D0=BD=D0=B8=D0=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Список с вкладками «Неоплаченные / Все» и суммой долга в шапке; в карточке —
фотография с камеры первой, до реквизитов: по ней водитель узнаёт свою машину и
свою поездку. Дальше сумма, скидка с датой (то, ради чего заходят вовремя),
скорость и разрешённый предел, постановление, камера, ссылка на PDF.
Строка «Долг по штрафам» на главном экране теперь ведёт сюда, а не сразу к
оплате: платить, не поняв за что, человек не станет. Аренда по-прежнему ведёт к
оплате — за тем её и открывают.
Co-Authored-By: Claude Opus 5
---
driver-pwa/frontend/src/App.tsx | 4 +
driver-pwa/frontend/src/api/driver.ts | 39 ++++++
.../frontend/src/pages/BalancePage.test.tsx | 11 +-
driver-pwa/frontend/src/pages/BalancePage.tsx | 11 +-
.../frontend/src/pages/FineDetailPage.tsx | 122 ++++++++++++++++++
.../frontend/src/pages/FinesPage.test.tsx | 100 ++++++++++++++
driver-pwa/frontend/src/pages/FinesPage.tsx | 93 +++++++++++++
7 files changed, 375 insertions(+), 5 deletions(-)
create mode 100644 driver-pwa/frontend/src/pages/FineDetailPage.tsx
create mode 100644 driver-pwa/frontend/src/pages/FinesPage.test.tsx
create 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 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 ₽» не отвечает на вопрос «за что», а платить,
+ // не поняв за что, человек не станет.