refactor(pwa): штрафы живут только на главном экране
Отдельный список штрафов удалён: два списка одного и того же в разных местах сбивали с толку. Фильтр «Штрафы» внизу главного экрана теперь показывает сами постановления, а не проводки по ним, и ведёт в карточку с фотографией. Разница между постановлением и проводкой здесь принципиальная: штраф, который водитель оплатил сам, парк не выставлял — проводки нет, и в выписке он бы не появился вовсе (Морозов, штраф от 16.08). Строка «Долг по штрафам» вверху теперь включает этот фильтр, а не уводит на другой экран. Период действует и на штрафы: календарь над списком не должен стоять, ничего не меняя. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,6 @@ import { PaymentPage } from "@/pages/PaymentPage";
|
|||||||
import { DevLoginPage } from "@/pages/DevLoginPage";
|
import { DevLoginPage } from "@/pages/DevLoginPage";
|
||||||
import { DebugPage } from "@/pages/DebugPage";
|
import { DebugPage } from "@/pages/DebugPage";
|
||||||
import { MockPayPage } from "@/pages/MockPayPage";
|
import { MockPayPage } from "@/pages/MockPayPage";
|
||||||
import { FinesPage } from "@/pages/FinesPage";
|
|
||||||
import { FineDetailPage } from "@/pages/FineDetailPage";
|
import { FineDetailPage } from "@/pages/FineDetailPage";
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: ReactNode }) {
|
function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
@@ -44,7 +43,6 @@ export default function App() {
|
|||||||
<Route path="/topup" element={<RequireAuth><TopupPage /></RequireAuth>} />
|
<Route path="/topup" element={<RequireAuth><TopupPage /></RequireAuth>} />
|
||||||
<Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></RequireAuth>} />
|
<Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></RequireAuth>} />
|
||||||
<Route path="/mock-pay/:orderId" element={<RequireAuth><MockPayPage /></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="/fines/:fineId" element={<RequireAuth><FineDetailPage /></RequireAuth>} />
|
||||||
<Route path="/debug" element={<RequireAuth><RequireStaff><DebugPage /></RequireStaff></RequireAuth>} />
|
<Route path="/debug" element={<RequireAuth><RequireStaff><DebugPage /></RequireStaff></RequireAuth>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight, Image as ImageIcon } from "lucide-react";
|
||||||
import { getStatement, type StatementKind } from "@/api/driver";
|
import { getFines, getStatement, type StatementKind } from "@/api/driver";
|
||||||
|
import { fineStatusText, isOwed } from "@/lib/fineStatus";
|
||||||
import { formatDate, formatMoney } from "@/lib/format";
|
import { formatDate, formatMoney } from "@/lib/format";
|
||||||
import { OperationIcon } from "@/components/operationIcon";
|
import { OperationIcon } from "@/components/operationIcon";
|
||||||
import { Spinner } from "@/components/Spinner";
|
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 }: {
|
export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
|
||||||
from: string;
|
from: string;
|
||||||
@@ -28,11 +32,28 @@ export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
|
|||||||
onKind: (k: StatementKind) => void;
|
onKind: (k: StatementKind) => void;
|
||||||
}) {
|
}) {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const showFines = kind === "fines";
|
||||||
|
|
||||||
|
const ops = useQuery({
|
||||||
queryKey: ["statement", from, to, kind],
|
queryKey: ["statement", from, to, kind],
|
||||||
queryFn: () => getStatement(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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -53,7 +74,7 @@ export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
|
|||||||
key={f.key}
|
key={f.key}
|
||||||
onClick={() => onKind(f.key)}
|
onClick={() => onKind(f.key)}
|
||||||
className={`shrink-0 px-3 py-2 rounded-xl2 text-xs border ${
|
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}
|
{f.label}
|
||||||
@@ -61,25 +82,49 @@ export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{active.isLoading ? (
|
||||||
<div className="flex justify-center py-8"><Spinner /></div>
|
<div className="flex justify-center py-8"><Spinner /></div>
|
||||||
) : isError ? (
|
) : active.isError ? (
|
||||||
<div className="text-center py-6">
|
<div className="text-center py-6">
|
||||||
<p className="text-muted text-sm mb-3">Не удалось загрузить операции</p>
|
<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>
|
</div>
|
||||||
) : items.length === 0 ? (
|
|
||||||
<p className="text-muted text-sm text-center py-8">Нет операций за выбранный период</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{items.map((op, idx) => {
|
{opRows.map((op, idx) => {
|
||||||
const row = (
|
const row = (
|
||||||
<>
|
<>
|
||||||
<OperationIcon title={op.title} />
|
<OperationIcon title={op.title} />
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
{/* Дата — первой строкой и чернильным цветом: в выписке ищут
|
{/* Дата — первой строкой: в выписке ищут «что было 12-го», а не
|
||||||
«что было 12-го», а не название операции, и бледная подпись
|
название операции. */}
|
||||||
под заголовком для этого не годится. */}
|
|
||||||
<span className="block text-xs font-semibold">{formatDate(op.date)}</span>
|
<span className="block text-xs font-semibold">{formatDate(op.date)}</span>
|
||||||
<span className="block text-sm">{op.title}</span>
|
<span className="block text-sm">{op.title}</span>
|
||||||
{op.description && (
|
{op.description && (
|
||||||
@@ -91,17 +136,14 @@ export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
|
|||||||
</b>
|
</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 ? (
|
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}`)}>
|
onClick={() => nav(`/fines/${op.fine_id}`)}>
|
||||||
{row}
|
{row}
|
||||||
<ChevronRight size={16} className="text-muted shrink-0" />
|
<ChevronRight size={16} className="text-muted shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div key={`${op.date}-${idx}`} className={cls}>{row}</div>
|
<div key={`${op.date}-${idx}`} className={rowCls}>{row}</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ vi.mock("@/api/driver", () => ({
|
|||||||
synced_at: null,
|
synced_at: null,
|
||||||
pending_topup: 1500,
|
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 () => ({
|
getStatement: vi.fn(async () => ({
|
||||||
items: [
|
items: [
|
||||||
{ date: "2026-08-17", title: "Аренда автомобиля", description: "ТС А947УТ125",
|
{ date: "2026-08-17", title: "Аренда автомобиля", description: "ТС А947УТ125",
|
||||||
@@ -38,7 +46,6 @@ function wrap() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<BalancePage />} />
|
<Route path="/" element={<BalancePage />} />
|
||||||
<Route path="/topup" element={<p>экран пополнения</p>} />
|
<Route path="/topup" element={<p>экран пополнения</p>} />
|
||||||
<Route path="/fines" element={<p>экран штрафов</p>} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
@@ -64,9 +71,18 @@ describe("главный экран", () => {
|
|||||||
|
|
||||||
it("фильтр перезапрашивает операции выбранного вида", async () => {
|
it("фильтр перезапрашивает операции выбранного вида", async () => {
|
||||||
wrap();
|
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;
|
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 () => {
|
it("аренда и штрафы видны сразу, остальные статьи — нет", async () => {
|
||||||
@@ -86,10 +102,11 @@ describe("главный экран", () => {
|
|||||||
expect(await screen.findByText("экран пополнения")).toBeInTheDocument();
|
expect(await screen.findByText("экран пополнения")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("строка штрафов открывает список, а не оплату: «за что» важнее кнопки", async () => {
|
it("строка штрафов включает фильтр внизу, а не уводит на другой экран", async () => {
|
||||||
wrap();
|
wrap();
|
||||||
await userEvent.click(await screen.findByRole("button", { name: /Долг по штрафам/ }));
|
await userEvent.click(await screen.findByRole("button", { name: /Долг по штрафам/ }));
|
||||||
expect(await screen.findByText("экран штрафов")).toBeInTheDocument();
|
expect(await screen.findByText(/Превышение скорости/)).toBeInTheDocument();
|
||||||
|
expect(driver.getFines).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("категории для выбора живут в шторке «Пополнить»", async () => {
|
it("категории для выбора живут в шторке «Пополнить»", async () => {
|
||||||
|
|||||||
@@ -65,15 +65,16 @@ export function BalancePage() {
|
|||||||
{main.length > 0 && (
|
{main.length > 0 && (
|
||||||
<div className="mt-3 pt-3 border-t border-line">
|
<div className="mt-3 pt-3 border-t border-line">
|
||||||
{main.map((a) => (
|
{main.map((a) => (
|
||||||
// Аренда ведёт к оплате — за тем её и открывают. Штрафы ведут в свой
|
// Аренда ведёт к оплате — за тем её и открывают. Штрафы включают
|
||||||
// список: «−5 156 ₽» не отвечает на вопрос «за что», а платить,
|
// фильтр в списке ниже: «−5 156 ₽» не отвечает на вопрос «за что»,
|
||||||
// не поняв за что, человек не станет.
|
// а отдельного экрана штрафов нет — два списка одного и того же в
|
||||||
|
// разных местах сбивали с толку.
|
||||||
<button
|
<button
|
||||||
key={a.bucket}
|
key={a.bucket}
|
||||||
disabled={!a.payable}
|
disabled={!a.payable}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
a.bucket === "Долг по штрафам"
|
a.bucket === "Долг по штрафам"
|
||||||
? nav("/fines")
|
? setKind("fines")
|
||||||
: nav("/topup", { state: { bucket: a.bucket } })
|
: nav("/topup", { state: { bucket: a.bucket } })
|
||||||
}
|
}
|
||||||
className="w-full flex justify-between items-baseline py-1.5 text-left disabled:opacity-100"
|
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">
|
<div className="pt-16 text-center">
|
||||||
<p className="text-sm font-semibold mb-1">Штраф не найден</p>
|
<p className="text-sm font-semibold mb-1">Штраф не найден</p>
|
||||||
<p className="text-muted text-xs mb-6">Возможно, он больше не числится за вами.</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-2 pb-4">
|
<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 ? (
|
{f.photos.length > 0 ? (
|
||||||
<div className="mb-4 space-y-2">
|
<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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user