feat(pwa): экран штрафов с фотографиями нарушений

Список с вкладками «Неоплаченные / Все» и суммой долга в шапке; в карточке —
фотография с камеры первой, до реквизитов: по ней водитель узнаёт свою машину и
свою поездку. Дальше сумма, скидка с датой (то, ради чего заходят вовремя),
скорость и разрешённый предел, постановление, камера, ссылка на PDF.

Строка «Долг по штрафам» на главном экране теперь ведёт сюда, а не сразу к
оплате: платить, не поняв за что, человек не станет. Аренда по-прежнему ведёт к
оплате — за тем её и открывают.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 17:18:58 +10:00
co-authored by Claude Opus 5
parent 9187b5766e
commit f4a9da25cf
7 changed files with 375 additions and 5 deletions
@@ -0,0 +1,93 @@
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 { formatDate, formatMoney } from "@/lib/format";
import { OperationIcon } from "@/components/operationIcon";
import { Spinner } from "@/components/Spinner";
/**
* Штрафы водителя списком.
*
* Строка «Долг по штрафам −5 156 ₽» на балансе не отвечает на вопрос «за что»,
* и водитель идёт выяснять звонком. Здесь он видит каждое постановление, а в
* карточке — фотографию с камеры.
*/
export function FinesPage() {
const nav = useNavigate();
const [tab, setTab] = useState<"open" | "all">("open");
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["fines", tab],
queryFn: () => getFines(tab),
});
const items = data?.items ?? [];
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.open_count > 0
? `Не оплачено: ${data.open_count} на ${formatMoney(data.open_sum)}`
: "Неоплаченных нет"
: " "}
</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 ${
tab === key ? "border-line 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">
{tab === "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="block text-sm truncate">{f.article}</span>
<span className="block text-muted text-xs flex items-center gap-1.5">
{formatDate(f.date)}
{f.car_number ? ` · ${f.car_number}` : ""}
{f.has_photo && <ImageIcon size={12} strokeWidth={1.5} />}
</span>
</span>
<span className="shrink-0 text-right">
<b className={f.paid ? "text-muted" : "text-neg"}>{formatMoney(f.amount)}</b>
<span className="block text-muted text-[11px]">
{f.cancelled ? "отменён" : f.paid ? "оплачен" : "не оплачен"}
</span>
</span>
<ChevronRight size={16} className="text-muted shrink-0" />
</button>
))}
</div>
)}
</div>
);
}