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 { BalancePage } from "@/pages/BalancePage";
|
||||||
import { TopupPage } from "@/pages/TopupPage";
|
import { TopupPage } from "@/pages/TopupPage";
|
||||||
import { PaymentPage } from "@/pages/PaymentPage";
|
import { PaymentPage } from "@/pages/PaymentPage";
|
||||||
import { HistoryPage } from "@/pages/HistoryPage";
|
|
||||||
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";
|
||||||
@@ -45,7 +44,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="/history" element={<RequireAuth><HistoryPage /></RequireAuth>} />
|
|
||||||
<Route path="/fines" element={<RequireAuth><FinesPage /></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>} />
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ export const StatementSchema = z.object({
|
|||||||
description: z.string().default(""),
|
description: z.string().default(""),
|
||||||
amount: z.number(),
|
amount: z.number(),
|
||||||
kind: z.enum(["charges", "payments", "fines"]),
|
kind: z.enum(["charges", "payments", "fines"]),
|
||||||
|
// Карточка штрафа, если постановление у нас есть. null — строка не ссылка.
|
||||||
|
fine_id: z.number().nullable().default(null),
|
||||||
})),
|
})),
|
||||||
from: z.string(),
|
from: z.string(),
|
||||||
to: z.string(),
|
to: z.string(),
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
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 { getStatement, type StatementKind } from "@/api/driver";
|
||||||
import { formatDate, formatMoney } from "@/lib/format";
|
import { formatDate, formatMoney } from "@/lib/format";
|
||||||
import { OperationIcon } from "@/components/operationIcon";
|
import { OperationIcon } from "@/components/operationIcon";
|
||||||
@@ -25,6 +27,7 @@ export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
|
|||||||
onTo: (v: string) => void;
|
onTo: (v: string) => void;
|
||||||
onKind: (k: StatementKind) => void;
|
onKind: (k: StatementKind) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const nav = useNavigate();
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ["statement", from, to, kind],
|
queryKey: ["statement", from, to, kind],
|
||||||
queryFn: () => getStatement(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>
|
<p className="text-muted text-sm text-center py-8">Нет операций за выбранный период</p>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{items.map((op, idx) => (
|
{items.map((op, idx) => {
|
||||||
<div key={`${op.date}-${idx}`}
|
const row = (
|
||||||
className="flex items-center gap-3 py-2.5 border-b border-line last:border-0">
|
<>
|
||||||
<OperationIcon title={op.title} />
|
<OperationIcon title={op.title} />
|
||||||
<span className="min-w-0 flex-1">
|
<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-sm">{op.title}</span>
|
||||||
<span className="block text-muted text-xs">
|
{op.description && (
|
||||||
{formatDate(op.date)}
|
<span className="block text-muted text-xs truncate">{op.description}</span>
|
||||||
{op.description ? ` · ${op.description}` : ""}
|
)}
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
<b className={`shrink-0 ${op.amount < 0 ? "text-neg" : "text-pos"}`}>
|
<b className={`shrink-0 ${op.amount < 0 ? "text-neg" : "text-pos"}`}>
|
||||||
{formatMoney(op.amount)}
|
{formatMoney(op.amount)}
|
||||||
</b>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -112,6 +112,15 @@ describe("главный экран", () => {
|
|||||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
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 () => {
|
it("баланс не загрузился — экран говорит об этом, а не рисует ноль", async () => {
|
||||||
vi.mocked(driver.getBalance).mockRejectedValueOnce(new Error("boom"));
|
vi.mocked(driver.getBalance).mockRejectedValueOnce(new Error("boom"));
|
||||||
wrap();
|
wrap();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
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 { ArrowUp, ChevronRight, History, LogOut } from "lucide-react";
|
import { ArrowUp, ChevronRight, LogOut } from "lucide-react";
|
||||||
import { getBalance, type StatementKind } from "@/api/driver";
|
import { getBalance, type StatementKind } from "@/api/driver";
|
||||||
import { formatMoney, isoDaysAgo, isoToday } from "@/lib/format";
|
import { formatMoney, isoDaysAgo, isoToday } from "@/lib/format";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
@@ -108,10 +108,9 @@ export function BalancePage() {
|
|||||||
<Statement from={from} to={to} kind={kind}
|
<Statement from={from} to={to} kind={kind}
|
||||||
onFrom={setFrom} onTo={setTo} onKind={setKind} />
|
onFrom={setFrom} onTo={setTo} onKind={setKind} />
|
||||||
|
|
||||||
<div className="flex justify-center gap-6 mt-6 text-muted text-xs">
|
<div className="flex justify-center mt-6 text-muted text-xs">
|
||||||
<button className="flex items-center gap-1.5" onClick={() => nav("/history")}>
|
{/* Отдельного раздела «Мои пополнения» нет намеренно: пополнения видны в
|
||||||
<History size={15} /> Мои пополнения
|
выписке выше по фильтру, а дублирующий пустой экран сбивал с толку. */}
|
||||||
</button>
|
|
||||||
<button className="flex items-center gap-1.5" onClick={() => { clear(); nav("/login"); }}>
|
<button className="flex items-center gap-1.5" onClick={() => { clear(); nav("/login"); }}>
|
||||||
<LogOut size={15} /> Выйти
|
<LogOut size={15} /> Выйти
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -76,12 +76,14 @@ export function FinesPage() {
|
|||||||
>
|
>
|
||||||
<OperationIcon title="Штраф" />
|
<OperationIcon title="Штраф" />
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block text-sm truncate">{f.article}</span>
|
<span className="flex items-center gap-1.5 text-xs font-semibold">
|
||||||
<span className="block text-muted text-xs flex items-center gap-1.5">
|
|
||||||
{formatDate(f.date)}
|
{formatDate(f.date)}
|
||||||
{f.car_number ? ` · ${f.car_number}` : ""}
|
{f.has_photo && <ImageIcon size={12} strokeWidth={1.5} className="text-muted" />}
|
||||||
{f.has_photo && <ImageIcon size={12} strokeWidth={1.5} />}
|
|
||||||
</span>
|
</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>
|
||||||
<span className="shrink-0 text-right">
|
<span className="shrink-0 text-right">
|
||||||
<b className={isOwed(f) ? "text-neg" : "text-muted"}>{formatMoney(f.amount)}</b>
|
<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