feat(pwa): переход в штраф из выписки, заметные даты, минус раздел пополнений
Дата в выписке и в списке штрафов встала первой строкой чернильным цветом: в этих списках ищут «что было 12-го», а бледная подпись под заголовком для этого не годилась. Строка штрафа в выписке стала кнопкой в карточку — там фотография и подробности. Остальные строки не притворяются ссылками. Раздел «Мои пополнения» удалён вместе с кнопкой: он показывал только наши платежи и у большинства водителей был пуст (1С проводит пополнения сама), а те же операции видны в выписке фильтром «Пополнения». Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>} />
|
||||
|
||||
@@ -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">
|
||||
<OperationIcon title={op.title} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm">{op.title}</span>
|
||||
<span className="block text-muted text-xs">
|
||||
{formatDate(op.date)}
|
||||
{op.description ? ` · ${op.description}` : ""}
|
||||
{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>
|
||||
{op.description && (
|
||||
<span className="block text-muted text-xs truncate">{op.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<b className={`shrink-0 ${op.amount < 0 ? "text-neg" : "text-pos"}`}>
|
||||
{formatMoney(op.amount)}
|
||||
</b>
|
||||
</div>
|
||||
))}
|
||||
<b className={`shrink-0 ${op.amount < 0 ? "text-neg" : "text-pos"}`}>
|
||||
{formatMoney(op.amount)}
|
||||
</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}
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user