feat(pwa): переход в штраф из выписки, заметные даты, минус раздел пополнений

Дата в выписке и в списке штрафов встала первой строкой чернильным цветом: в
этих списках ищут «что было 12-го», а бледная подпись под заголовком для этого
не годилась.

Строка штрафа в выписке стала кнопкой в карточку — там фотография и подробности.
Остальные строки не притворяются ссылками.

Раздел «Мои пополнения» удалён вместе с кнопкой: он показывал только наши
платежи и у большинства водителей был пуст (1С проводит пополнения сама), а те
же операции видны в выписке фильтром «Пополнения».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 17:58:28 +10:00
co-authored by Claude Opus 5
parent 0c7735ef85
commit 3bdd5ce818
8 changed files with 55 additions and 118 deletions
-2
View File
@@ -9,7 +9,6 @@ import { OnboardingPage } from "@/pages/OnboardingPage";
import { BalancePage } from "@/pages/BalancePage";
import { TopupPage } from "@/pages/TopupPage";
import { PaymentPage } from "@/pages/PaymentPage";
import { HistoryPage } from "@/pages/HistoryPage";
import { DevLoginPage } from "@/pages/DevLoginPage";
import { DebugPage } from "@/pages/DebugPage";
import { MockPayPage } from "@/pages/MockPayPage";
@@ -45,7 +44,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="/history" element={<RequireAuth><HistoryPage /></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>} />
+2
View File
@@ -127,6 +127,8 @@ export const StatementSchema = z.object({
description: z.string().default(""),
amount: z.number(),
kind: z.enum(["charges", "payments", "fines"]),
// Карточка штрафа, если постановление у нас есть. null — строка не ссылка.
fine_id: z.number().nullable().default(null),
})),
from: z.string(),
to: z.string(),
@@ -1,4 +1,6 @@
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 { formatDate, formatMoney } from "@/lib/format";
import { OperationIcon } from "@/components/operationIcon";
@@ -25,6 +27,7 @@ export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
onTo: (v: string) => void;
onKind: (k: StatementKind) => void;
}) {
const nav = useNavigate();
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["statement", from, to, kind],
queryFn: () => getStatement(from, to, kind),
@@ -69,22 +72,38 @@ export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
<p className="text-muted text-sm text-center py-8">Нет операций за выбранный период</p>
) : (
<div>
{items.map((op, idx) => (
<div key={`${op.date}-${idx}`}
className="flex items-center gap-3 py-2.5 border-b border-line last:border-0">
{items.map((op, idx) => {
const row = (
<>
<OperationIcon title={op.title} />
<span className="min-w-0 flex-1">
{/* Дата — первой строкой и чернильным цветом: в выписке ищут
«что было 12-го», а не название операции, и бледная подпись
под заголовком для этого не годится. */}
<span className="block text-xs font-semibold">{formatDate(op.date)}</span>
<span className="block text-sm">{op.title}</span>
<span className="block text-muted text-xs">
{formatDate(op.date)}
{op.description ? ` · ${op.description}` : ""}
</span>
{op.description && (
<span className="block text-muted text-xs truncate">{op.description}</span>
)}
</span>
<b className={`shrink-0 ${op.amount < 0 ? "text-neg" : "text-pos"}`}>
{formatMoney(op.amount)}
</b>
</div>
))}
</>
);
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}
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>
)}
</div>
@@ -112,6 +112,15 @@ describe("главный экран", () => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("отдельного раздела пополнений нет — они в выписке", async () => {
// Раздел «Мои пополнения» показывал наши платежи и у большинства водителей был
// пуст: 1С проводит пополнения сама, и они уже видны фильтром в выписке.
wrap();
await screen.findByText("8 976,25 ₽");
expect(screen.queryByText(/Мои пополнения/)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Пополнения" })).toBeInTheDocument();
});
it("баланс не загрузился — экран говорит об этом, а не рисует ноль", async () => {
vi.mocked(driver.getBalance).mockRejectedValueOnce(new Error("boom"));
wrap();
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { ArrowUp, ChevronRight, History, LogOut } from "lucide-react";
import { ArrowUp, ChevronRight, LogOut } from "lucide-react";
import { getBalance, type StatementKind } from "@/api/driver";
import { formatMoney, isoDaysAgo, isoToday } from "@/lib/format";
import { useAuth } from "@/store/auth";
@@ -108,10 +108,9 @@ export function BalancePage() {
<Statement from={from} to={to} kind={kind}
onFrom={setFrom} onTo={setTo} onKind={setKind} />
<div className="flex justify-center gap-6 mt-6 text-muted text-xs">
<button className="flex items-center gap-1.5" onClick={() => nav("/history")}>
<History size={15} /> Мои пополнения
</button>
<div className="flex justify-center mt-6 text-muted text-xs">
{/* Отдельного раздела «Мои пополнения» нет намеренно: пополнения видны в
выписке выше по фильтру, а дублирующий пустой экран сбивал с толку. */}
<button className="flex items-center gap-1.5" onClick={() => { clear(); nav("/login"); }}>
<LogOut size={15} /> Выйти
</button>
+6 -4
View File
@@ -76,12 +76,14 @@ export function FinesPage() {
>
<OperationIcon title="Штраф" />
<span className="min-w-0 flex-1">
<span className="block text-sm truncate">{f.article}</span>
<span className="block text-muted text-xs flex items-center gap-1.5">
<span className="flex items-center gap-1.5 text-xs font-semibold">
{formatDate(f.date)}
{f.car_number ? ` · ${f.car_number}` : ""}
{f.has_photo && <ImageIcon size={12} strokeWidth={1.5} />}
{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>
@@ -1,25 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("@/api/driver", () => ({
getPayments: vi.fn(async () => ({ payments: [
{ order_id: "o1", bucket: "Долг аренда", amount: 1000, commission: 52, method: "qr", status: "PAID", created_at: "2026-06-18T09:00:00Z", paid_at: "2026-06-18T09:01:00Z" },
] })),
}));
import { HistoryPage } from "./HistoryPage";
function wrap() {
const qc = new QueryClient();
return render(<QueryClientProvider client={qc}><MemoryRouter><HistoryPage /></MemoryRouter></QueryClientProvider>);
}
describe("HistoryPage", () => {
it("lists payments with amount + status", async () => {
wrap();
expect(await screen.findByText("Долг аренда")).toBeInTheDocument();
expect(screen.getByText("1 000 ₽")).toBeInTheDocument();
expect(screen.getByText(/Оплачено/i)).toBeInTheDocument();
});
});
@@ -1,67 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { getPayments } from "@/api/driver";
import { formatMoney } from "@/lib/format";
import { Spinner } from "@/components/Spinner";
/**
* Все статусы, которые может прислать бэкенд. Раньше их было пять из тринадцати,
* и водитель видел в истории «DEADLINE_EXPIRED» или «ATTEMPTS_EXPIRED» латиницей,
* а возврат денег выглядел ровно как обычный отказ.
*/
const STATUS_RU: Record<string, string> = {
NEW: "Ожидает оплаты",
FORM: "Ожидает оплаты",
PAID: "Оплачено",
CONFIRMED: "Оплачено",
REJECTED: "Отклонён банком",
AUTH_FAIL: "Отклонён банком",
CANCELED: "Отменён",
DEADLINE_EXPIRED: "Истёк срок оплаты",
ATTEMPTS_EXPIRED: "Исчерпаны попытки оплаты",
ERROR: "Ошибка",
REVERSED: "Деньги возвращены",
REFUNDED: "Деньги возвращены",
PARTIAL_REVERSED: "Возвращена часть",
PARTIAL_REFUNDED: "Возвращена часть",
};
const RETURNED = new Set(["REVERSED", "REFUNDED", "PARTIAL_REVERSED", "PARTIAL_REFUNDED"]);
export function HistoryPage() {
const nav = useNavigate();
const { data, isLoading, isError } = useQuery({ queryKey: ["payments"], queryFn: getPayments });
const rows = data?.payments ?? [];
return (
<div className="pt-2">
<button className="text-muted text-xs mb-3" onClick={() => nav("/")}> Назад</button>
<h1 className="text-lg font-extrabold mb-4">История пополнений</h1>
{isLoading ? (
<div className="flex justify-center py-10"><Spinner /></div>
) : isError ? (
<p className="text-muted text-sm">Не удалось загрузить историю</p>
) : rows.length === 0 ? (
<p className="text-muted text-sm">Пока нет пополнений</p>
) : (
rows.map((p) => {
const returned = RETURNED.has(p.status) || !!p.reversed_at;
return (
<div key={p.order_id} className="flex justify-between items-center py-3 border-b border-line">
<div>
<p className="text-sm">{p.bucket}</p>
<p className="text-muted text-xs">
{p.created_at ? new Date(p.created_at).toLocaleDateString("ru-RU") : ""} ·{" "}
{STATUS_RU[p.status] ?? p.status}
</p>
</div>
<b className={returned ? "text-muted line-through" : undefined}>
{formatMoney(p.amount)}
</b>
</div>
);
})
)}
</div>
);
}