feat(mechanic): раздел «История ремонта» в PWA
Задача CRM 4439. Плитка в меню действий → поиск по госномеру → список ремонтов машины (дата, госномер, модель, пробег, состав работ) → существующая карточка ремонта. Вход в историю есть и прямо из карточки авто, там же блок последних ремонтов. Что стоило внимания: - поиск ищет и по машинам, выведенным из парка (их ремонты никуда не делись, и в осмотрах такая машина видна), но не по проданным — парк ими не занимается. Текст пустого результата говорит ровно это: механик с опечаткой в номере делает вывод по этой строке, и она обязана совпадать с поведением бэкенда; - сбой сети отличим от «ничего не нашли», и под ошибкой не остаётся выдача прошлого запроса — иначе можно тапнуть и уехать в историю чужой машины; - «Назад» возвращает туда, откуда пришли (лента, история машины, карточка авто), а не в ленту всего парка. Прямая ссылка при пустом стеке не запирает экран; - форма правки больше не оставляет лишнюю запись в стеке: до этого «Назад» из карточки ремонта после «Отмены» возвращал обратно в форму правки; - история и блок в карточке авто запрашивают ленту без фото — превью там не рисуется, а бэкенд ради него лазил в хранилище на каждую строку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import RepairDetailPage from "@/pages/repairs/RepairDetailPage";
|
||||
import EditRepairPage from "@/pages/repairs/EditRepairPage";
|
||||
import CarSelectPage from "@/pages/repairs/CarSelectPage";
|
||||
import CreateRepairPage from "@/pages/repairs/CreateRepairPage";
|
||||
import RepairHistoryPage from "@/pages/repairs/RepairHistoryPage";
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
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/:carId" 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/edit" element={<RequireAuth><EditRepairPage /></RequireAuth>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -102,9 +102,19 @@ export interface CreateRepairResponse {
|
||||
tg_outbox_status: string;
|
||||
}
|
||||
|
||||
export async function searchCars(q: string): Promise<CarBrief[]> {
|
||||
const qs = q ? `?q=${encodeURIComponent(q)}` : "";
|
||||
return api.get(`cars${qs}`).json<CarBrief[]>();
|
||||
export interface SearchCarsOptions {
|
||||
// Искать по всему парку, а не только по активным машинам. Нужно «Истории
|
||||
// ремонта»: ремонты выведенной машины никуда не делись, их ищут и после.
|
||||
// Создание ремонта этим НЕ пользуется — там годятся только активные.
|
||||
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> {
|
||||
|
||||
@@ -17,6 +17,9 @@ export interface FeedItem {
|
||||
works_extra: number;
|
||||
works_total: number; // сумма работ без запчастей
|
||||
photo_url: string | null;
|
||||
// Пробег на момент ремонта. У старых ремонтов не заполнен — показываем
|
||||
// это честно, а не нулём (экран «История ремонта»).
|
||||
mileage?: number | null;
|
||||
}
|
||||
|
||||
export interface FeedResponse {
|
||||
@@ -27,12 +30,19 @@ export interface FeedResponse {
|
||||
export interface FeedParams {
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
// Фильтр по машине — лента становится историей ремонтов одного авто.
|
||||
car_id?: number;
|
||||
// Превью-фото. false — экран превью не рисует, и backend не ходит за
|
||||
// ссылками в хранилище. По умолчанию (не передаём) фото приходят.
|
||||
with_photos?: boolean;
|
||||
}
|
||||
|
||||
export async function getRepairsFeed(params: FeedParams = {}): Promise<FeedResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.limit) search.set("limit", String(params.limit));
|
||||
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();
|
||||
return api
|
||||
.get(`repairs${qs ? `?${qs}` : ""}`)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ClipboardCheck, History, Wrench } from "lucide-react";
|
||||
import { getMe } from "@/api/me";
|
||||
import { logout } from "@/api/auth";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -9,7 +11,8 @@ interface ActionTile {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
// Иконка из lucide-react (пак всего PWA); эмодзи в интерфейсе не используем.
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
comingSoon?: boolean;
|
||||
@@ -24,16 +27,23 @@ export default function ActionMenu() {
|
||||
key: "inspect",
|
||||
title: "Осмотр",
|
||||
description: "Создать или продолжить осмотр машины",
|
||||
emoji: "🔍",
|
||||
icon: ClipboardCheck,
|
||||
onClick: () => navigate("/vehicles?action=inspect"),
|
||||
},
|
||||
{
|
||||
key: "repair",
|
||||
title: "Ремонт",
|
||||
description: "Лента ремонтов парка + создать новый",
|
||||
emoji: "🔧",
|
||||
icon: Wrench,
|
||||
onClick: () => navigate("/repairs"),
|
||||
},
|
||||
{
|
||||
key: "repair-history",
|
||||
title: "История ремонта",
|
||||
description: "Что чинили на машине — поиск по госномеру",
|
||||
icon: History,
|
||||
onClick: () => navigate("/repairs/history"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -62,7 +72,9 @@ export default function ActionMenu() {
|
||||
Что делаем?
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{tiles.map((t) => (
|
||||
{tiles.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<Card
|
||||
key={t.key}
|
||||
onClick={t.disabled ? undefined : t.onClick}
|
||||
@@ -74,7 +86,7 @@ export default function ActionMenu() {
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-3xl leading-none">{t.emoji}</div>
|
||||
<Icon className="h-8 w-8 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-base">{t.title}</h3>
|
||||
@@ -90,7 +102,8 @@ export default function ActionMenu() {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
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 { toast } from "sonner";
|
||||
import { ChevronRight, Wrench } from "lucide-react";
|
||||
import { getVehicle, getTtHistory } from "@/api/vehicles";
|
||||
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 { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
/** Сколько последних ремонтов показывать прямо в карточке — остальное
|
||||
* за кнопкой «История ремонта». */
|
||||
const RECENT_REPAIRS_LIMIT = 3;
|
||||
|
||||
const INSPECTION_TYPE_LABELS: Record<InspectionType, string> = {
|
||||
handover: "Выдача",
|
||||
return: "Приёмка",
|
||||
@@ -42,11 +49,16 @@ function timeAgo(iso: string): string {
|
||||
export default function VehicleCard() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const vehicleId = Number(id);
|
||||
|
||||
const action = searchParams.get("action") || undefined;
|
||||
|
||||
// Куда вернуть механика с экранов ремонта: сюда же, вместе с ?action —
|
||||
// иначе «Назад» уводит в общую ленту парка, и машину ищи заново.
|
||||
const backHere = `${location.pathname}${location.search}`;
|
||||
|
||||
const [pendingType, setPendingType] = useState<InspectionType | null>(null);
|
||||
const [mileageInput, setMileageInput] = useState("");
|
||||
const [mileageSource, setMileageSource] = useState<
|
||||
@@ -67,6 +79,29 @@ export default function VehicleCard() {
|
||||
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({
|
||||
mutationFn: (input: { type: InspectionType; mileage: number | undefined }) =>
|
||||
createInspection({ vehicle_id: vehicleId, type: input.type, mileage: input.mileage }),
|
||||
@@ -169,6 +204,86 @@ export default function VehicleCard() {
|
||||
</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>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
Прошлые осмотры
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* НЕ редактируются — для смены фото нужно дождаться PR-9.
|
||||
*/
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { searchWorks, suggestParts, type MechWork } from "@/api/repairsCreate";
|
||||
import WorksPickerModal from "@/components/repairs/WorksPickerModal";
|
||||
import MechanicsField, { type SelectedMechanic } from "@/components/repairs/MechanicsField";
|
||||
import { hasHistoryToGoBack } from "./RepairDetailPage";
|
||||
|
||||
interface WorkRow {
|
||||
rowKey: string;
|
||||
@@ -52,6 +53,7 @@ function fmt(n: number): string {
|
||||
export default function EditRepairPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [detail, setDetail] = useState<RepairDetailResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -111,6 +113,18 @@ export default function EditRepairPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [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]);
|
||||
|
||||
// Works picker — поиск по каталогу.
|
||||
@@ -199,7 +213,7 @@ export default function EditRepairPage() {
|
||||
? { mechanic_ids: mechanics.map((m) => m.mechanic_id) }
|
||||
: {}),
|
||||
});
|
||||
navigate(`/repairs/${detail.id}`, { replace: true });
|
||||
leaveForm();
|
||||
} catch (e) {
|
||||
setError((e as Error).message || "Не удалось сохранить");
|
||||
setSaving(false);
|
||||
@@ -211,7 +225,7 @@ export default function EditRepairPage() {
|
||||
<header className="sticky top-0 z-10 border-b bg-background/95 backdrop-blur">
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
onClick={leaveForm}
|
||||
className="rounded-full p-2 hover:bg-accent"
|
||||
aria-label="Назад"
|
||||
>
|
||||
@@ -393,7 +407,7 @@ export default function EditRepairPage() {
|
||||
{/* Sticky footer */}
|
||||
{detail && (
|
||||
<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 onClick={submit} disabled={saving || !detail.can_edit} className="flex-1">
|
||||
|
||||
@@ -8,10 +8,32 @@
|
||||
* Endpoint: GET /api/v1/mechanic/repairs/{id}
|
||||
*/
|
||||
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 { 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 {
|
||||
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() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
@@ -61,12 +84,39 @@ export default function RepairDetailPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 z-10 border-b bg-background/95 backdrop-blur">
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<button
|
||||
onClick={() => navigate("/repairs")}
|
||||
onClick={goBack}
|
||||
className="rounded-full p-2 hover:bg-accent"
|
||||
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).
|
||||
*/
|
||||
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 { ChevronLeft, Plus, Wrench } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -19,6 +19,7 @@ import { groupByLocalDay, formatDayLabel, fmtMoney, pluralRepairs, pluralWorks }
|
||||
|
||||
export default function RepairsFeedPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const {
|
||||
items,
|
||||
isLoading,
|
||||
@@ -66,7 +67,11 @@ export default function RepairsFeedPage() {
|
||||
}, [refetch]);
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
|
||||
Reference in New Issue
Block a user