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 { 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() {
|
||||
<Route path="/topup" element={<RequireAuth><TopupPage /></RequireAuth>} />
|
||||
<Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></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="/debug" element={<RequireAuth><RequireStaff><DebugPage /></RequireStaff></RequireAuth>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
@@ -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 }: {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
{active.isLoading ? (
|
||||
<div className="flex justify-center py-8"><Spinner /></div>
|
||||
) : isError ? (
|
||||
) : active.isError ? (
|
||||
<div className="text-center py-6">
|
||||
<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>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-muted text-sm text-center py-8">Нет операций за выбранный период</p>
|
||||
) : (
|
||||
<div>
|
||||
{items.map((op, idx) => {
|
||||
{opRows.map((op, idx) => {
|
||||
const row = (
|
||||
<>
|
||||
<OperationIcon title={op.title} />
|
||||
<span className="min-w-0 flex-1">
|
||||
{/* Дата — первой строкой и чернильным цветом: в выписке ищут
|
||||
«что было 12-го», а не название операции, и бледная подпись
|
||||
под заголовком для этого не годится. */}
|
||||
{/* Дата — первой строкой: в выписке ищут «что было 12-го», а не
|
||||
название операции. */}
|
||||
<span className="block text-xs font-semibold">{formatDate(op.date)}</span>
|
||||
<span className="block text-sm">{op.title}</span>
|
||||
{op.description && (
|
||||
@@ -91,17 +136,14 @@ export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
|
||||
</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 ? (
|
||||
<button key={`${op.date}-${idx}`} className={cls}
|
||||
<button key={`${op.date}-${idx}`} className={rowCls}
|
||||
onClick={() => nav(`/fines/${op.fine_id}`)}>
|
||||
{row}
|
||||
<ChevronRight size={16} className="text-muted shrink-0" />
|
||||
</button>
|
||||
) : (
|
||||
<div key={`${op.date}-${idx}`} className={cls}>{row}</div>
|
||||
<div key={`${op.date}-${idx}`} className={rowCls}>{row}</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<Routes>
|
||||
<Route path="/" element={<BalancePage />} />
|
||||
<Route path="/topup" element={<p>экран пополнения</p>} />
|
||||
<Route path="/fines" element={<p>экран штрафов</p>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -65,15 +65,16 @@ export function BalancePage() {
|
||||
{main.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-line">
|
||||
{main.map((a) => (
|
||||
// Аренда ведёт к оплате — за тем её и открывают. Штрафы ведут в свой
|
||||
// список: «−5 156 ₽» не отвечает на вопрос «за что», а платить,
|
||||
// не поняв за что, человек не станет.
|
||||
// Аренда ведёт к оплате — за тем её и открывают. Штрафы включают
|
||||
// фильтр в списке ниже: «−5 156 ₽» не отвечает на вопрос «за что»,
|
||||
// а отдельного экрана штрафов нет — два списка одного и того же в
|
||||
// разных местах сбивали с толку.
|
||||
<button
|
||||
key={a.bucket}
|
||||
disabled={!a.payable}
|
||||
onClick={() =>
|
||||
a.bucket === "Долг по штрафам"
|
||||
? nav("/fines")
|
||||
? setKind("fines")
|
||||
: nav("/topup", { state: { bucket: a.bucket } })
|
||||
}
|
||||
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">
|
||||
<p className="text-sm font-semibold mb-1">Штраф не найден</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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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 ? (
|
||||
<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