feat(mechanic): раздел «История ремонта» в PWA

Задача CRM 4439. Плитка в меню действий → поиск по госномеру → список ремонтов
машины (дата, госномер, модель, пробег, состав работ) → существующая карточка
ремонта. Вход в историю есть и прямо из карточки авто, там же блок последних
ремонтов.

Что стоило внимания:
- поиск ищет и по машинам, выведенным из парка (их ремонты никуда не делись,
  и в осмотрах такая машина видна), но не по проданным — парк ими не занимается.
  Текст пустого результата говорит ровно это: механик с опечаткой в номере
  делает вывод по этой строке, и она обязана совпадать с поведением бэкенда;
- сбой сети отличим от «ничего не нашли», и под ошибкой не остаётся выдача
  прошлого запроса — иначе можно тапнуть и уехать в историю чужой машины;
- «Назад» возвращает туда, откуда пришли (лента, история машины, карточка авто),
  а не в ленту всего парка. Прямая ссылка при пустом стеке не запирает экран;
- форма правки больше не оставляет лишнюю запись в стеке: до этого «Назад» из
  карточки ремонта после «Отмены» возвращал обратно в форму правки;
- история и блок в карточке авто запрашивают ленту без фото — превью там не
  рисуется, а бэкенд ради него лазил в хранилище на каждую строку.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 18:24:21 +10:00
co-authored by Claude Opus 5
parent 1dca1fe3c9
commit 5dbf9b1549
9 changed files with 727 additions and 43 deletions
+7
View File
@@ -13,6 +13,7 @@ import RepairDetailPage from "@/pages/repairs/RepairDetailPage";
import EditRepairPage from "@/pages/repairs/EditRepairPage"; import EditRepairPage from "@/pages/repairs/EditRepairPage";
import CarSelectPage from "@/pages/repairs/CarSelectPage"; import CarSelectPage from "@/pages/repairs/CarSelectPage";
import CreateRepairPage from "@/pages/repairs/CreateRepairPage"; import CreateRepairPage from "@/pages/repairs/CreateRepairPage";
import RepairHistoryPage from "@/pages/repairs/RepairHistoryPage";
function RequireAuth({ children }: { children: React.ReactNode }) { function RequireAuth({ children }: { children: React.ReactNode }) {
const token = useAuth((s) => s.token); const token = useAuth((s) => s.token);
@@ -40,6 +41,12 @@ export default function App() {
<Route path="/repairs/new" element={<RequireAuth><CarSelectPage /></RequireAuth>} /> <Route path="/repairs/new" element={<RequireAuth><CarSelectPage /></RequireAuth>} />
<Route path="/repairs/new/:carId" element={<RequireAuth><CreateRepairPage /></RequireAuth>} /> <Route path="/repairs/new/:carId" element={<RequireAuth><CreateRepairPage /></RequireAuth>} />
<Route path="/repairs/new-external" element={<RequireAuth><CreateRepairPage /></RequireAuth>} /> <Route path="/repairs/new-external" element={<RequireAuth><CreateRepairPage /></RequireAuth>} />
{/* «История ремонта». Держим выше /repairs/:id по тому же принципу, что и
/repairs/new: react-router ранжирует (статический сегмент весит больше
динамического), так что перехвата не будет и при другом порядке, но
глазами читается сразу — статика раньше :id. */}
<Route path="/repairs/history" element={<RequireAuth><RepairHistoryPage /></RequireAuth>} />
<Route path="/repairs/history/:carId" element={<RequireAuth><RepairHistoryPage /></RequireAuth>} />
<Route path="/repairs/:id" element={<RequireAuth><RepairDetailPage /></RequireAuth>} /> <Route path="/repairs/:id" element={<RequireAuth><RepairDetailPage /></RequireAuth>} />
<Route path="/repairs/:id/edit" element={<RequireAuth><EditRepairPage /></RequireAuth>} /> <Route path="/repairs/:id/edit" element={<RequireAuth><EditRepairPage /></RequireAuth>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
+13 -3
View File
@@ -102,9 +102,19 @@ export interface CreateRepairResponse {
tg_outbox_status: string; tg_outbox_status: string;
} }
export async function searchCars(q: string): Promise<CarBrief[]> { export interface SearchCarsOptions {
const qs = q ? `?q=${encodeURIComponent(q)}` : ""; // Искать по всему парку, а не только по активным машинам. Нужно «Истории
return api.get(`cars${qs}`).json<CarBrief[]>(); // ремонта»: ремонты выведенной машины никуда не делись, их ищут и после.
// Создание ремонта этим НЕ пользуется — там годятся только активные.
includeInactive?: boolean;
}
export async function searchCars(q: string, opts: SearchCarsOptions = {}): Promise<CarBrief[]> {
const search = new URLSearchParams();
if (q) search.set("q", q);
if (opts.includeInactive) search.set("include_inactive", "true");
const qs = search.toString();
return api.get(`cars${qs ? `?${qs}` : ""}`).json<CarBrief[]>();
} }
export async function getCarContext(carId: number): Promise<CarContext> { export async function getCarContext(carId: number): Promise<CarContext> {
@@ -17,6 +17,9 @@ export interface FeedItem {
works_extra: number; works_extra: number;
works_total: number; // сумма работ без запчастей works_total: number; // сумма работ без запчастей
photo_url: string | null; photo_url: string | null;
// Пробег на момент ремонта. У старых ремонтов не заполнен — показываем
// это честно, а не нулём (экран «История ремонта»).
mileage?: number | null;
} }
export interface FeedResponse { export interface FeedResponse {
@@ -27,12 +30,19 @@ export interface FeedResponse {
export interface FeedParams { export interface FeedParams {
limit?: number; limit?: number;
cursor?: string | null; cursor?: string | null;
// Фильтр по машине — лента становится историей ремонтов одного авто.
car_id?: number;
// Превью-фото. false — экран превью не рисует, и backend не ходит за
// ссылками в хранилище. По умолчанию (не передаём) фото приходят.
with_photos?: boolean;
} }
export async function getRepairsFeed(params: FeedParams = {}): Promise<FeedResponse> { export async function getRepairsFeed(params: FeedParams = {}): Promise<FeedResponse> {
const search = new URLSearchParams(); const search = new URLSearchParams();
if (params.limit) search.set("limit", String(params.limit)); if (params.limit) search.set("limit", String(params.limit));
if (params.cursor) search.set("cursor", params.cursor); if (params.cursor) search.set("cursor", params.cursor);
if (params.car_id) search.set("car_id", String(params.car_id));
if (params.with_photos === false) search.set("with_photos", "false");
const qs = search.toString(); const qs = search.toString();
return api return api
.get(`repairs${qs ? `?${qs}` : ""}`) .get(`repairs${qs ? `?${qs}` : ""}`)
+43 -30
View File
@@ -1,5 +1,7 @@
import type { ComponentType } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ClipboardCheck, History, Wrench } from "lucide-react";
import { getMe } from "@/api/me"; import { getMe } from "@/api/me";
import { logout } from "@/api/auth"; import { logout } from "@/api/auth";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -9,7 +11,8 @@ interface ActionTile {
key: string; key: string;
title: string; title: string;
description: string; description: string;
emoji: string; // Иконка из lucide-react (пак всего PWA); эмодзи в интерфейсе не используем.
icon: ComponentType<{ className?: string }>;
onClick: () => void; onClick: () => void;
disabled?: boolean; disabled?: boolean;
comingSoon?: boolean; comingSoon?: boolean;
@@ -24,16 +27,23 @@ export default function ActionMenu() {
key: "inspect", key: "inspect",
title: "Осмотр", title: "Осмотр",
description: "Создать или продолжить осмотр машины", description: "Создать или продолжить осмотр машины",
emoji: "🔍", icon: ClipboardCheck,
onClick: () => navigate("/vehicles?action=inspect"), onClick: () => navigate("/vehicles?action=inspect"),
}, },
{ {
key: "repair", key: "repair",
title: "Ремонт", title: "Ремонт",
description: "Лента ремонтов парка + создать новый", description: "Лента ремонтов парка + создать новый",
emoji: "🔧", icon: Wrench,
onClick: () => navigate("/repairs"), onClick: () => navigate("/repairs"),
}, },
{
key: "repair-history",
title: "История ремонта",
description: "Что чинили на машине — поиск по госномеру",
icon: History,
onClick: () => navigate("/repairs/history"),
},
]; ];
return ( return (
@@ -62,35 +72,38 @@ export default function ActionMenu() {
Что делаем? Что делаем?
</h2> </h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{tiles.map((t) => ( {tiles.map((t) => {
<Card const Icon = t.icon;
key={t.key} return (
onClick={t.disabled ? undefined : t.onClick} <Card
className={ key={t.key}
"p-5 transition-shadow " + onClick={t.disabled ? undefined : t.onClick}
(t.disabled className={
? "opacity-50 cursor-not-allowed" "p-5 transition-shadow " +
: "cursor-pointer hover:shadow-md active:shadow-sm") (t.disabled
} ? "opacity-50 cursor-not-allowed"
> : "cursor-pointer hover:shadow-md active:shadow-sm")
<div className="flex items-start gap-3"> }
<div className="text-3xl leading-none">{t.emoji}</div> >
<div className="flex-1 min-w-0"> <div className="flex items-start gap-3">
<div className="flex items-center gap-2"> <Icon className="h-8 w-8 shrink-0 text-muted-foreground" />
<h3 className="font-semibold text-base">{t.title}</h3> <div className="flex-1 min-w-0">
{t.comingSoon && ( <div className="flex items-center gap-2">
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5"> <h3 className="font-semibold text-base">{t.title}</h3>
скоро {t.comingSoon && (
</span> <span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
)} скоро
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
{t.description}
</p>
</div> </div>
<p className="text-sm text-muted-foreground mt-1">
{t.description}
</p>
</div> </div>
</div> </Card>
</Card> );
))} })}
</div> </div>
</main> </main>
</div> </div>
+116 -1
View File
@@ -1,14 +1,21 @@
import { useState } from "react"; import { useState } from "react";
import { useParams, useNavigate, useSearchParams } from "react-router-dom"; import { useParams, useNavigate, useSearchParams, useLocation } from "react-router-dom";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { ChevronRight, Wrench } from "lucide-react";
import { getVehicle, getTtHistory } from "@/api/vehicles"; import { getVehicle, getTtHistory } from "@/api/vehicles";
import { createInspection, type InspectionType } from "@/api/inspections"; import { createInspection, type InspectionType } from "@/api/inspections";
import { getRepairsFeed, type FeedItem } from "@/api/repairsFeed";
import { fmtMoney, pluralWorks } from "@/lib/repairsFeed";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
/** Сколько последних ремонтов показывать прямо в карточке — остальное
* за кнопкой «История ремонта». */
const RECENT_REPAIRS_LIMIT = 3;
const INSPECTION_TYPE_LABELS: Record<InspectionType, string> = { const INSPECTION_TYPE_LABELS: Record<InspectionType, string> = {
handover: "Выдача", handover: "Выдача",
return: "Приёмка", return: "Приёмка",
@@ -42,11 +49,16 @@ function timeAgo(iso: string): string {
export default function VehicleCard() { export default function VehicleCard() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const vehicleId = Number(id); const vehicleId = Number(id);
const action = searchParams.get("action") || undefined; const action = searchParams.get("action") || undefined;
// Куда вернуть механика с экранов ремонта: сюда же, вместе с ?action —
// иначе «Назад» уводит в общую ленту парка, и машину ищи заново.
const backHere = `${location.pathname}${location.search}`;
const [pendingType, setPendingType] = useState<InspectionType | null>(null); const [pendingType, setPendingType] = useState<InspectionType | null>(null);
const [mileageInput, setMileageInput] = useState(""); const [mileageInput, setMileageInput] = useState("");
const [mileageSource, setMileageSource] = useState< const [mileageSource, setMileageSource] = useState<
@@ -67,6 +79,29 @@ export default function VehicleCard() {
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
// Ремонты именно этой машины (№4439) — самый частый вопрос механика.
// Берём ту же ленту ремонтов, но с фильтром по машине; в карточке
// показываем верхушку, вся история — на отдельном экране.
const repairsQuery = useQuery({
queryKey: ["vehicle", vehicleId, "repairs"],
queryFn: () =>
getRepairsFeed({
car_id: vehicleId,
limit: RECENT_REPAIRS_LIMIT,
// Превью фото здесь не рисуем — не гоняем backend в хранилище
// (на каждую строку: выборка фото, проверка миниатюры, подпись ссылки).
with_photos: false,
}),
enabled: Number.isFinite(vehicleId),
staleTime: 60_000,
// Запись ремонта происходит на другом экране и этот кэш не сбрасывает.
// Поэтому обновляем на каждый вход в карточку: иначе механик, вернувшись
// сразу после сохранения, видит блок без своего ремонта — как потерянный.
// Старые строки при этом остаются на экране, скелетон не мигает.
refetchOnMount: "always",
});
const recentRepairs: FeedItem[] = repairsQuery.data?.items ?? [];
const startInspection = useMutation({ const startInspection = useMutation({
mutationFn: (input: { type: InspectionType; mileage: number | undefined }) => mutationFn: (input: { type: InspectionType; mileage: number | undefined }) =>
createInspection({ vehicle_id: vehicleId, type: input.type, mileage: input.mileage }), createInspection({ vehicle_id: vehicleId, type: input.type, mileage: input.mileage }),
@@ -169,6 +204,86 @@ export default function VehicleCard() {
</div> </div>
</div> </div>
<div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
Последние ремонты
</h2>
{repairsQuery.isLoading ? (
<div className="space-y-2">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="h-16 rounded-lg bg-muted animate-pulse" />
))}
</div>
) : recentRepairs.length === 0 ? (
<Card className="p-4 text-sm text-muted-foreground">
{repairsQuery.isError
? "Не удалось загрузить ремонты."
: "Ремонтов ещё не было."}
</Card>
) : (
<div className="space-y-2">
{recentRepairs.map((r) => {
const works = r.works_preview.map((w) => w.name).join(" · ");
return (
<Card
key={r.id}
className="p-3 cursor-pointer hover:shadow-md transition-shadow"
onClick={() =>
navigate(`/repairs/${r.id}`, { state: { from: backHere } })
}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{works || "Ремонт"}
{r.works_extra > 0 && (
<span className="text-muted-foreground">
{" "}
· +{r.works_extra} {pluralWorks(r.works_extra)}
</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-2 flex-wrap">
<span>
{new Date(r.created_at).toLocaleDateString("ru-RU")}
</span>
{r.mileage != null && (
<>
<span className="text-muted-foreground/50">·</span>
<span>{r.mileage.toLocaleString("ru-RU")} км</span>
</>
)}
</div>
</div>
<div className="text-xs text-muted-foreground shrink-0 tabular-nums">
{fmtMoney(r.works_total || 0)}
</div>
</div>
</Card>
);
})}
</div>
)}
{/* Вход в историю оставляем и при ошибке загрузки — на отдельном
экране механик хотя бы сможет попробовать ещё раз. */}
{(recentRepairs.length > 0 || repairsQuery.isError) && (
<Button
variant="outline"
size="lg"
className="w-full mt-2"
onClick={() =>
navigate(`/repairs/history/${vehicleId}`, {
state: { from: backHere },
})
}
>
<Wrench className="h-4 w-4 mr-2" />
История ремонта
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
)}
</div>
<div> <div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2"> <h2 className="text-sm font-semibold text-muted-foreground mb-2">
Прошлые осмотры Прошлые осмотры
@@ -6,7 +6,7 @@
* НЕ редактируются — для смены фото нужно дождаться PR-9. * НЕ редактируются — для смены фото нужно дождаться PR-9.
*/ */
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useLocation, useNavigate, useParams } from "react-router-dom";
import { ChevronLeft, Plus, Trash2 } from "lucide-react"; import { ChevronLeft, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -19,6 +19,7 @@ import {
import { searchWorks, suggestParts, type MechWork } from "@/api/repairsCreate"; import { searchWorks, suggestParts, type MechWork } from "@/api/repairsCreate";
import WorksPickerModal from "@/components/repairs/WorksPickerModal"; import WorksPickerModal from "@/components/repairs/WorksPickerModal";
import MechanicsField, { type SelectedMechanic } from "@/components/repairs/MechanicsField"; import MechanicsField, { type SelectedMechanic } from "@/components/repairs/MechanicsField";
import { hasHistoryToGoBack } from "./RepairDetailPage";
interface WorkRow { interface WorkRow {
rowKey: string; rowKey: string;
@@ -52,6 +53,7 @@ function fmt(n: number): string {
export default function EditRepairPage() { export default function EditRepairPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [detail, setDetail] = useState<RepairDetailResponse | null>(null); const [detail, setDetail] = useState<RepairDetailResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -111,6 +113,18 @@ export default function EditRepairPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); }, [id]);
// Уход с формы — снятие её записи со стека, а не переход на карточку.
// Раньше «Отмена» и «Сохранить» вели navigate(`/repairs/${id}`): запись
// формы оставалась в истории, и «Назад» в карточке возвращал обратно в
// форму правки (или в ту же карточку — нужен был второй тап).
// Карточка при возврате монтируется заново и перечитывает ремонт, так что
// после сохранения там свежие данные.
const leaveForm = () => {
if (hasHistoryToGoBack(location.key)) navigate(-1);
// Форму открыли прямой ссылкой — шагать назад некуда, подменяем запись.
else navigate(id ? `/repairs/${id}` : "/repairs", { replace: true });
};
const totalSum = useMemo(() => works.reduce((s, w) => s + (w.price_applied || 0), 0), [works]); const totalSum = useMemo(() => works.reduce((s, w) => s + (w.price_applied || 0), 0), [works]);
// Works picker — поиск по каталогу. // Works picker — поиск по каталогу.
@@ -199,7 +213,7 @@ export default function EditRepairPage() {
? { mechanic_ids: mechanics.map((m) => m.mechanic_id) } ? { mechanic_ids: mechanics.map((m) => m.mechanic_id) }
: {}), : {}),
}); });
navigate(`/repairs/${detail.id}`, { replace: true }); leaveForm();
} catch (e) { } catch (e) {
setError((e as Error).message || "Не удалось сохранить"); setError((e as Error).message || "Не удалось сохранить");
setSaving(false); setSaving(false);
@@ -211,7 +225,7 @@ export default function EditRepairPage() {
<header className="sticky top-0 z-10 border-b bg-background/95 backdrop-blur"> <header className="sticky top-0 z-10 border-b bg-background/95 backdrop-blur">
<div className="flex items-center gap-3 p-3"> <div className="flex items-center gap-3 p-3">
<button <button
onClick={() => navigate(-1)} onClick={leaveForm}
className="rounded-full p-2 hover:bg-accent" className="rounded-full p-2 hover:bg-accent"
aria-label="Назад" aria-label="Назад"
> >
@@ -393,7 +407,7 @@ export default function EditRepairPage() {
{/* Sticky footer */} {/* Sticky footer */}
{detail && ( {detail && (
<footer className="fixed bottom-0 left-0 right-0 border-t bg-background p-3 flex gap-2 z-10"> <footer className="fixed bottom-0 left-0 right-0 border-t bg-background p-3 flex gap-2 z-10">
<Button variant="outline" onClick={() => navigate(`/repairs/${detail.id}`)} disabled={saving} className="flex-1"> <Button variant="outline" onClick={leaveForm} disabled={saving} className="flex-1">
Отмена Отмена
</Button> </Button>
<Button onClick={submit} disabled={saving || !detail.can_edit} className="flex-1"> <Button onClick={submit} disabled={saving || !detail.can_edit} className="flex-1">
@@ -8,10 +8,32 @@
* Endpoint: GET /api/v1/mechanic/repairs/{id} * Endpoint: GET /api/v1/mechanic/repairs/{id}
*/ */
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams, useLocation } from "react-router-dom";
import { ChevronLeft, Pencil } from "lucide-react"; import { ChevronLeft, Pencil } from "lucide-react";
import { getRepairDetail, type RepairDetailResponse } from "@/api/repairsFeed"; import { getRepairDetail, type RepairDetailResponse } from "@/api/repairsFeed";
/**
* Есть ли внутри приложения куда шагнуть назад. `idx` — номер записи, который
* router держит в history.state: 0 значит, что экран открыли прямой ссылкой
* (standalone-PWA стартует с пустым стеком) и navigate(-1) увёл бы наружу.
* Под MemoryRouter (тесты) idx нет — там стартовую запись видно по ключу.
*
* Экспортируется: тем же вопросом задаётся форма правки перед уходом с неё.
* Две копии проверки разъедутся при первой же правке.
*/
export function hasHistoryToGoBack(locationKey: string): boolean {
const idx = (window.history.state as { idx?: number } | null)?.idx;
return typeof idx === "number" ? idx > 0 : locationKey !== "default";
}
/**
* Бэкенд отдаёт в карточке `car_id` (null у ремонта «с улицы») — на нём держится
* фолбэк возврата в историю машины. В общем типе ответа поля пока нет, а
* api/repairsFeed.ts правится соседней задачей; объявляем локально, чтобы не
* писать в чужой файл. Появится `car_id` в RepairDetailResponse — алиас убрать.
*/
type RepairDetail = RepairDetailResponse & { car_id?: number | null };
function fmt(n: number): string { function fmt(n: number): string {
return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }).format(Math.round(n)); return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }).format(Math.round(n));
} }
@@ -27,7 +49,8 @@ function fmtDateTime(iso: string): string {
export default function RepairDetailPage() { export default function RepairDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [data, setData] = useState<RepairDetailResponse | null>(null); const location = useLocation();
const [data, setData] = useState<RepairDetail | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
@@ -61,12 +84,39 @@ export default function RepairDetailPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); }, [id]);
// Карточку открывают из трёх мест: лента парка, история машины, карточка
// авто — и каждое кладёт свой путь в `state.from` при переходе сюда (тем же
// способом CarSelectPage передаёт данные в CreateRepairPage). Жёсткий
// /repairs выбрасывал из истории машины в общую ленту: при разборе «что
// делали за год» механик заново искал госномер на каждый ремонт.
const fromState = location.state as { from?: string } | null;
// Только внутренний путь: чужой state не должен уводить из приложения.
// `//host` браузер считает ссылкой на другой сайт — такой «путь» отвергаем.
const backTo =
typeof fromState?.from === "string" &&
fromState.from.startsWith("/") &&
!fromState.from.startsWith("//")
? fromState.from
: null;
// Порядок: явный `from`; иначе шаг назад по стеку; иначе (прямая ссылка,
// стека нет) — история этой машины: «что с ней делали» ближе к тому, зачем
// открывали карточку, чем лента всего парка. У ремонта «с улицы» машины
// парка нет — там остаётся лента. Возврат не наращивает стек, поэтому
// переходы по адресу идут через replace.
const goBack = () => {
if (backTo) navigate(backTo, { replace: true });
else if (hasHistoryToGoBack(location.key)) navigate(-1);
else if (data?.car_id != null) navigate(`/repairs/history/${data.car_id}`, { replace: true });
else navigate("/repairs", { replace: true });
};
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<header className="sticky top-0 z-10 border-b bg-background/95 backdrop-blur"> <header className="sticky top-0 z-10 border-b bg-background/95 backdrop-blur">
<div className="flex items-center gap-3 p-3"> <div className="flex items-center gap-3 p-3">
<button <button
onClick={() => navigate("/repairs")} onClick={goBack}
className="rounded-full p-2 hover:bg-accent" className="rounded-full p-2 hover:bg-accent"
aria-label="Назад" aria-label="Назад"
> >
@@ -0,0 +1,460 @@
/**
* «История ремонта» (запрос механиков, задача 4439).
*
* Один компонент на два роута:
* /repairs/history — поиск машины по госномеру;
* /repairs/history/:carId — история ремонтов конкретной машины.
*
* Лента берётся тем же эндпоинтом, что и общая (`GET repairs`), только с
* фильтром `car_id`, поэтому курсорная пагинация и порядок `created_at DESC`
* работают как на главном экране раздела.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { HTTPError } from "ky";
import { ChevronLeft, ChevronRight, Search, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getCarContext, searchCars, type CarBrief } from "@/api/repairsCreate";
import { getRepairsFeed, type FeedItem, type FeedResponse } from "@/api/repairsFeed";
import { FEED_PAGE_SIZE } from "@/hooks/useRepairsFeed";
import { fmtMoney, pluralWorks, toLocalDateKey } from "@/lib/repairsFeed";
import { cn } from "@/lib/utils";
/** ДД.ММ.ГГГГ. Через toLocalDateKey — чтобы день совпадал с группировкой ленты (VLA). */
function fmtDate(iso: string): string {
const [y, m, d] = toLocalDateKey(iso).split("-");
return `${d}.${m}.${y}`;
}
export default function RepairHistoryPage() {
const { carId } = useParams<{ carId?: string }>();
const id = carId ? Number(carId) : NaN;
return Number.isFinite(id) && id > 0 ? <CarHistory carId={id} /> : <PlateSearch />;
}
// ── Поиск машины по госномеру ──────────────────────────────────────────────
// Debounce 200 мс и тот же searchCars, что в CarSelectPage (релевантность по
// номеру считает backend). Отличий два: ищем и по машинам, выведенным из парка
// (Activity из 1С снялась, а ремонты никуда не делись — в осмотрах такая машина
// видна, и в истории должна быть), но БЕЗ проданных — парк ими не занимается;
// и не ходим в сеть, пока номер короче двух символов — на одном символе выдача
// = почти весь парк, а тянуть его целиком незачем.
const MIN_QUERY_LEN = 2;
// Набранный номер переживает уход в историю машины и возврат назад: механик
// сравнивает две похожие машины и не должен набирать номер заново на каждое
// сравнение. Живёт в памяти вкладки — после перезапуска приложения поиск
// открывается чистым.
let lastQuery = "";
function PlateSearch() {
const navigate = useNavigate();
const [q, setQ] = useState(lastQuery);
const [cars, setCars] = useState<CarBrief[]>([]);
const [loading, setLoading] = useState(false);
// Ошибка — флаг, а не текст: сообщение ky английское и про HTTP, механику
// оно ничего не объясняет.
const [failed, setFailed] = useState(false);
const [retryTick, setRetryTick] = useState(0);
const query = q.trim();
const tooShort = query.length < MIN_QUERY_LEN;
const onQueryChange = (value: string) => {
lastQuery = value;
setQ(value);
};
useEffect(() => {
if (tooShort) {
setCars([]);
setLoading(false);
setFailed(false);
return;
}
let cancelled = false;
setLoading(true);
setFailed(false);
const t = setTimeout(async () => {
try {
const list = await searchCars(query, { includeInactive: true });
if (!cancelled) setCars(list);
} catch {
// Выдачу прошлого запроса гасим: иначе под сообщением об ошибке
// остаются кликабельные машины, и тап уводит в историю не той машины.
if (!cancelled) {
setCars([]);
setFailed(true);
}
} finally {
if (!cancelled) setLoading(false);
}
}, 200); // debounce
return () => {
cancelled = true;
clearTimeout(t);
};
}, [query, tooShort, retryTick]);
return (
<div className="min-h-screen bg-background flex flex-col">
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="flex items-center gap-2 p-3">
<Button variant="ghost" size="sm" onClick={() => navigate("/")} className="-ml-2" aria-label="Назад">
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="text-base font-semibold">История ремонта</h1>
</div>
<div className="px-3 pb-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<Input
autoFocus
placeholder="Госномер…"
value={q}
onChange={(e) => onQueryChange(e.target.value)}
className="h-12 pl-10 text-base"
/>
</div>
</div>
</header>
{/* Ветки взаимоисключающие: «сеть отвалилась» и «такой машины нет» —
разные ответы, и рисовать их вместе нельзя. */}
<main className="flex-1 container max-w-2xl py-3">
{tooShort ? (
<div className="flex flex-col items-center justify-center text-center py-16 px-4 space-y-3">
<div className="rounded-full bg-muted p-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h2 className="text-base font-semibold">Найдите машину по госномеру</h2>
<p className="text-sm text-muted-foreground max-w-xs">
Введите хотя бы два символа например, 123 или В45.
</p>
</div>
) : loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-14 rounded-lg bg-muted animate-pulse" />
))}
</div>
) : failed ? (
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm space-y-3">
<p>
Не удалось выполнить поиск похоже, нет связи. Это сбой сети, а не
«машины нет»: попробуйте ещё раз.
</p>
<Button variant="outline" size="sm" onClick={() => setRetryTick((n) => n + 1)}>
Повторить
</Button>
</div>
) : cars.length === 0 ? (
<div className="flex flex-col items-center justify-center text-center py-16 px-4 space-y-3">
<div className="rounded-full bg-muted p-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h2 className="text-base font-semibold">Ничего не нашли по такому номеру</h2>
{/* Текст обязан совпадать с фильтром бэкенда: выведенные из парка
ищутся, проданные — нет. Иначе механик с опечаткой в номере
сделает неверный вывод о том, есть ли история вообще. */}
<p className="text-sm text-muted-foreground max-w-xs">
Проверьте номер проданные машины в истории не ищутся.
</p>
</div>
) : (
<ul className="space-y-2">
{cars.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => navigate(`/repairs/history/${c.id}`)}
className="w-full flex items-center gap-3 rounded-lg border bg-card hover:bg-accent/40 active:bg-accent/60 transition-colors p-4 text-left"
>
<div className="flex-1 min-w-0">
<div className="text-base font-semibold font-mono">{c.plate}</div>
<div className="text-sm text-muted-foreground truncate">
{[c.brand, c.model].filter(Boolean).join(" ")}
</div>
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
</button>
</li>
))}
</ul>
)}
</main>
</div>
);
}
// ── История ремонтов машины ────────────────────────────────────────────────
function CarHistory({ carId }: { carId: number }) {
const navigate = useNavigate();
const location = useLocation();
// Экран открывают и по прямой ссылке: из карточки авто, а в standalone-PWA
// приложение может и перезапуститься прямо на нём. Тогда идти назад некуда
// (key === "default" — первая запись в стеке), и navigate(-1) молча ничего
// не делает — механик заперт. В этом случае уводим на поиск по номеру.
const goBack = () => {
if (location.key === "default") navigate("/repairs/history", { replace: true });
else navigate(-1);
};
// Карточка авто нужна ради заголовка: если ремонтов ноль, взять госномер
// из ленты неоткуда.
const carQuery = useQuery({
queryKey: ["car-context", carId],
queryFn: () => getCarContext(carId),
staleTime: 5 * 60_000,
});
const feed = useInfiniteQuery<FeedResponse>({
queryKey: ["repairs-feed", "car", carId],
queryFn: ({ pageParam }) =>
getRepairsFeed({
limit: FEED_PAGE_SIZE,
cursor: (pageParam as string | null) ?? null,
car_id: carId,
// Превью в строках истории нет — не заставляем backend ходить в
// хранилище за ссылками на фото.
with_photos: false,
}),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined,
staleTime: 30_000,
refetchOnWindowFocus: false, // свой visibilitychange ниже — как в useRepairsFeed
});
const items: FeedItem[] = feed.data?.pages.flatMap((p) => p.items) ?? [];
const { hasNextPage, isFetchingNextPage, fetchNextPage, refetch: refetchFeed } = feed;
const { refetch: refetchCar } = carQuery;
// Обновление руками и по возврату в приложение — как в ленте парка
// (RepairsFeedPage + useRepairsFeed): свой visibilitychange вместо
// refetchOnWindowFocus. Заголовок перезапрашиваем вместе с лентой — после
// сбоя сети госномера на экране нет.
const handleRefresh = useCallback(() => {
void refetchFeed();
void refetchCar();
}, [refetchFeed, refetchCar]);
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === "visible") handleRefresh();
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, [handleRefresh]);
// Ретраи ky лежат поверх ретраев react-query, поэтому до ошибки экран может
// молчать минутами. Через 10 секунд говорим, что всё ещё грузим.
const [slow, setSlow] = useState(false);
const feedLoading = feed.isLoading;
useEffect(() => {
if (!feedLoading) return;
const t = setTimeout(() => setSlow(true), 10_000);
// Сброс — в уборке: загрузка кончилась, признак «долго» больше не наш.
return () => {
clearTimeout(t);
setSlow(false);
};
}, [feedLoading]);
// Догрузка страниц — как в ленте парка (IntersectionObserver, запас 200px).
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = sentinelRef.current;
if (!el || !hasNextPage) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
void fetchNextPage();
}
},
{ rootMargin: "200px" },
);
io.observe(el);
return () => io.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const car = carQuery.data;
const first = items[0];
// Ссылку на историю открывают из карточки авто и присылают в чат — carId
// бывает битым. Госномер тогда взять неоткуда: слово «Машина» стояло на его
// месте и читалось как номер («все ремонты Машина»).
const plate = car?.plate ?? first?.car_plate ?? null;
const carGone = carQuery.error instanceof HTTPError && carQuery.error.response.status === 404;
const model = car
? [car.brand, car.model].filter(Boolean).join(" ")
: [first?.car_make, first?.car_model].filter(Boolean).join(" ");
return (
<div className="min-h-screen bg-background flex flex-col">
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="flex items-center gap-2 p-3">
<Button variant="ghost" size="sm" onClick={goBack} className="-ml-2" aria-label="Назад">
<ChevronLeft className="h-5 w-5" />
</Button>
<div className="flex-1 min-w-0">
{/* Моноширинный — только для настоящего госномера. */}
<h1 className={cn("text-base font-semibold truncate", plate && "font-mono")}>
{plate ?? (carGone ? "Машина не найдена" : "История ремонта")}
</h1>
{plate && (
<div className="text-xs text-muted-foreground truncate">
История ремонта{model ? ` · ${model}` : ""}
</div>
)}
</div>
</div>
</header>
<main className="flex-1 container max-w-2xl py-3 space-y-2">
{/* Обновить — как в ленте парка: экран держат открытым, а ремонт мог
записать другой механик. */}
<div className="flex items-center justify-end">
<button
type="button"
onClick={handleRefresh}
disabled={feed.isFetching}
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
>
{feed.isFetching ? "Обновляем…" : "Обновить"}
</button>
</div>
{feed.isLoading && (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-28 rounded-lg bg-muted animate-pulse" />
))}
{slow && (
<p className="text-center text-xs text-muted-foreground py-2">
Сеть отвечает медленно история ещё грузится.
</p>
)}
</div>
)}
{feed.isError && !feed.isLoading && (
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm space-y-3">
<p>Не удалось загрузить историю ремонтов похоже, нет связи.</p>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={feed.isFetching}>
{feed.isFetching ? "Обновляем…" : "Повторить"}
</Button>
</div>
)}
{carGone && !feed.isLoading && !feed.isError && items.length === 0 && (
<div className="flex flex-col items-center justify-center text-center py-16 px-4 space-y-3">
<div className="rounded-full bg-muted p-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h2 className="text-base font-semibold">Такой машины нет</h2>
<p className="text-sm text-muted-foreground max-w-xs">
Ссылка ведёт на машину, которой нет в базе. Найдите её по госномеру.
</p>
<Button
variant="outline"
size="sm"
onClick={() => navigate("/repairs/history", { replace: true })}
>
Искать по номеру
</Button>
</div>
)}
{/* Ждём и карточку авто: иначе на битой ссылке сначала мигнёт
«ремонтов пока нет», и только потом придёт 404. */}
{!feed.isLoading && !feed.isError && !carQuery.isLoading && !carGone && items.length === 0 && (
<div className="flex flex-col items-center justify-center text-center py-16 px-4 space-y-3">
<div className="rounded-full bg-muted p-4">
<Wrench className="h-8 w-8 text-muted-foreground" />
</div>
<h2 className="text-base font-semibold">У этой машины ремонтов пока нет</h2>
<p className="text-sm text-muted-foreground max-w-xs">
{plate
? `Здесь появятся все ремонты ${plate}, как только их запишут.`
: "Здесь появятся ремонты этой машины, как только их запишут."}
</p>
</div>
)}
{items.length > 0 && (
<ul className="space-y-2">
{items.map((item) => (
<li key={item.id}>
<HistoryRow item={item} onOpen={() => navigate(`/repairs/${item.id}`)} />
</li>
))}
</ul>
)}
<div ref={sentinelRef} aria-hidden="true" />
{isFetchingNextPage && (
<div className="text-center text-xs text-muted-foreground py-4">Загружаем ещё</div>
)}
{!hasNextPage && items.length > 0 && !isFetchingNextPage && (
<div className="text-center text-xs text-muted-foreground py-4">Это вся история.</div>
)}
</main>
</div>
);
}
function HistoryRow({ item, onOpen }: { item: FeedItem; onOpen: () => void }) {
const model = [item.car_make, item.car_model].filter(Boolean).join(" ");
const works = item.works_preview.map((w) => w.name);
return (
<button
type="button"
onClick={onOpen}
className="w-full flex items-center gap-3 rounded-lg border bg-card hover:bg-accent/40 active:bg-accent/60 transition-colors text-left p-4"
>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-baseline justify-between gap-3">
<span className="text-base font-semibold tabular-nums">{fmtDate(item.created_at)}</span>
<span className="text-sm font-semibold tabular-nums flex-shrink-0">
{fmtMoney(item.works_total || 0)}
</span>
</div>
<div className="text-sm text-muted-foreground truncate">
<span className="font-mono">{item.car_plate}</span>
{model && ` · ${model}`}
</div>
{/* Пробег у старых ремонтов не заполняли — пишем это словами, «0 км» соврало бы. */}
<div className="text-sm">
{item.mileage != null ? (
<>
<span className="text-muted-foreground">Пробег: </span>
<span className="font-mono">{fmtMoney(item.mileage)} км</span>
</>
) : (
<span className="text-muted-foreground">Пробег не записан</span>
)}
</div>
<div className="text-sm leading-snug">
{works.length > 0 ? (
<>
{works.join(" · ")}
{item.works_extra > 0 && (
<span className="text-muted-foreground">
{" "}
· +{item.works_extra} {pluralWorks(item.works_extra)}
</span>
)}
</>
) : (
<span className="text-muted-foreground">Работы не указаны</span>
)}
</div>
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
</button>
);
}
@@ -8,7 +8,7 @@
* Пустая лента — плейсхолдер «В парке пока нет ремонтов» (У41). * Пустая лента — плейсхолдер «В парке пока нет ремонтов» (У41).
*/ */
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, Plus, Wrench } from "lucide-react"; import { ChevronLeft, Plus, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -19,6 +19,7 @@ import { groupByLocalDay, formatDayLabel, fmtMoney, pluralRepairs, pluralWorks }
export default function RepairsFeedPage() { export default function RepairsFeedPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { const {
items, items,
isLoading, isLoading,
@@ -66,7 +67,11 @@ export default function RepairsFeedPage() {
}, [refetch]); }, [refetch]);
const handleAdd = () => navigate("/repairs/new"); const handleAdd = () => navigate("/repairs/new");
const handleOpen = (item: FeedItem) => navigate(`/repairs/${item.id}`); // Куда вернуть механика из карточки ремонта: сюда же. Без этого признака
// «Назад» в карточке гадает по стеку истории (см. RepairDetailPage).
const backHere = `${location.pathname}${location.search}`;
const handleOpen = (item: FeedItem) =>
navigate(`/repairs/${item.id}`, { state: { from: backHere } });
return ( return (
<div className="min-h-screen bg-background flex flex-col"> <div className="min-h-screen bg-background flex flex-col">