diff --git a/mechanic-pwa/frontend/src/App.tsx b/mechanic-pwa/frontend/src/App.tsx index 9ab210c..a9d72e7 100644 --- a/mechanic-pwa/frontend/src/App.tsx +++ b/mechanic-pwa/frontend/src/App.tsx @@ -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() { } /> } /> } /> + {/* «История ремонта». Держим выше /repairs/:id по тому же принципу, что и + /repairs/new: react-router ранжирует (статический сегмент весит больше + динамического), так что перехвата не будет и при другом порядке, но + глазами читается сразу — статика раньше :id. */} + } /> + } /> } /> } /> } /> diff --git a/mechanic-pwa/frontend/src/api/repairsCreate.ts b/mechanic-pwa/frontend/src/api/repairsCreate.ts index bdbd5e4..bd70bd1 100644 --- a/mechanic-pwa/frontend/src/api/repairsCreate.ts +++ b/mechanic-pwa/frontend/src/api/repairsCreate.ts @@ -102,9 +102,19 @@ export interface CreateRepairResponse { tg_outbox_status: string; } -export async function searchCars(q: string): Promise { - const qs = q ? `?q=${encodeURIComponent(q)}` : ""; - return api.get(`cars${qs}`).json(); +export interface SearchCarsOptions { + // Искать по всему парку, а не только по активным машинам. Нужно «Истории + // ремонта»: ремонты выведенной машины никуда не делись, их ищут и после. + // Создание ремонта этим НЕ пользуется — там годятся только активные. + includeInactive?: boolean; +} + +export async function searchCars(q: string, opts: SearchCarsOptions = {}): Promise { + 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(); } export async function getCarContext(carId: number): Promise { diff --git a/mechanic-pwa/frontend/src/api/repairsFeed.ts b/mechanic-pwa/frontend/src/api/repairsFeed.ts index 8b1e122..4803fc5 100644 --- a/mechanic-pwa/frontend/src/api/repairsFeed.ts +++ b/mechanic-pwa/frontend/src/api/repairsFeed.ts @@ -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 { 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}` : ""}`) diff --git a/mechanic-pwa/frontend/src/pages/ActionMenu.tsx b/mechanic-pwa/frontend/src/pages/ActionMenu.tsx index b3d43c6..7cfc0eb 100644 --- a/mechanic-pwa/frontend/src/pages/ActionMenu.tsx +++ b/mechanic-pwa/frontend/src/pages/ActionMenu.tsx @@ -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,35 +72,38 @@ export default function ActionMenu() { Что делаем?
- {tiles.map((t) => ( - -
-
{t.emoji}
-
-
-

{t.title}

- {t.comingSoon && ( - - скоро - - )} + {tiles.map((t) => { + const Icon = t.icon; + return ( + +
+ +
+
+

{t.title}

+ {t.comingSoon && ( + + скоро + + )} +
+

+ {t.description} +

-

- {t.description} -

-
- - ))} + + ); + })}
diff --git a/mechanic-pwa/frontend/src/pages/VehicleCard.tsx b/mechanic-pwa/frontend/src/pages/VehicleCard.tsx index f7d195a..b417284 100644 --- a/mechanic-pwa/frontend/src/pages/VehicleCard.tsx +++ b/mechanic-pwa/frontend/src/pages/VehicleCard.tsx @@ -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 = { 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(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() {
+
+

+ Последние ремонты +

+ {repairsQuery.isLoading ? ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+ ))} +
+ ) : recentRepairs.length === 0 ? ( + + {repairsQuery.isError + ? "Не удалось загрузить ремонты." + : "Ремонтов ещё не было."} + + ) : ( +
+ {recentRepairs.map((r) => { + const works = r.works_preview.map((w) => w.name).join(" · "); + return ( + + navigate(`/repairs/${r.id}`, { state: { from: backHere } }) + } + > +
+
+
+ {works || "Ремонт"} + {r.works_extra > 0 && ( + + {" "} + · +{r.works_extra} {pluralWorks(r.works_extra)} + + )} +
+
+ + {new Date(r.created_at).toLocaleDateString("ru-RU")} + + {r.mileage != null && ( + <> + · + {r.mileage.toLocaleString("ru-RU")} км + + )} +
+
+
+ {fmtMoney(r.works_total || 0)} ₽ +
+
+
+ ); + })} +
+ )} + {/* Вход в историю оставляем и при ошибке загрузки — на отдельном + экране механик хотя бы сможет попробовать ещё раз. */} + {(recentRepairs.length > 0 || repairsQuery.isError) && ( + + )} +
+

Прошлые осмотры diff --git a/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx index ce1e710..37f6bb7 100644 --- a/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx +++ b/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx @@ -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(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() {
+

История ремонта

+
+
+
+ + onQueryChange(e.target.value)} + className="h-12 pl-10 text-base" + /> +
+
+
+ + {/* Ветки взаимоисключающие: «сеть отвалилась» и «такой машины нет» — + разные ответы, и рисовать их вместе нельзя. */} +
+ {tooShort ? ( +
+
+ +
+

Найдите машину по госномеру

+

+ Введите хотя бы два символа — например, 123 или В45. +

+
+ ) : loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : failed ? ( +
+

+ Не удалось выполнить поиск — похоже, нет связи. Это сбой сети, а не + «машины нет»: попробуйте ещё раз. +

+ +
+ ) : cars.length === 0 ? ( +
+
+ +
+

Ничего не нашли по такому номеру

+ {/* Текст обязан совпадать с фильтром бэкенда: выведенные из парка + ищутся, проданные — нет. Иначе механик с опечаткой в номере + сделает неверный вывод о том, есть ли история вообще. */} +

+ Проверьте номер — проданные машины в истории не ищутся. +

+
+ ) : ( +
    + {cars.map((c) => ( +
  • + +
  • + ))} +
+ )} +
+

+ ); +} + +// ── История ремонтов машины ──────────────────────────────────────────────── + +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({ + 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(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 ( +
+
+
+ +
+ {/* Моноширинный — только для настоящего госномера. */} +

+ {plate ?? (carGone ? "Машина не найдена" : "История ремонта")} +

+ {plate && ( +
+ История ремонта{model ? ` · ${model}` : ""} +
+ )} +
+
+
+ +
+ {/* Обновить — как в ленте парка: экран держат открытым, а ремонт мог + записать другой механик. */} +
+ +
+ + {feed.isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} + {slow && ( +

+ Сеть отвечает медленно — история ещё грузится. +

+ )} +
+ )} + + {feed.isError && !feed.isLoading && ( +
+

Не удалось загрузить историю ремонтов — похоже, нет связи.

+ +
+ )} + + {carGone && !feed.isLoading && !feed.isError && items.length === 0 && ( +
+
+ +
+

Такой машины нет

+

+ Ссылка ведёт на машину, которой нет в базе. Найдите её по госномеру. +

+ +
+ )} + + {/* Ждём и карточку авто: иначе на битой ссылке сначала мигнёт + «ремонтов пока нет», и только потом придёт 404. */} + {!feed.isLoading && !feed.isError && !carQuery.isLoading && !carGone && items.length === 0 && ( +
+
+ +
+

У этой машины ремонтов пока нет

+

+ {plate + ? `Здесь появятся все ремонты ${plate}, как только их запишут.` + : "Здесь появятся ремонты этой машины, как только их запишут."} +

+
+ )} + + {items.length > 0 && ( +
    + {items.map((item) => ( +
  • + navigate(`/repairs/${item.id}`)} /> +
  • + ))} +
+ )} + +
+
+ ); +} + +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 ( + + ); +} diff --git a/mechanic-pwa/frontend/src/pages/repairs/RepairsFeedPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/RepairsFeedPage.tsx index 9536441..a70aa66 100644 --- a/mechanic-pwa/frontend/src/pages/repairs/RepairsFeedPage.tsx +++ b/mechanic-pwa/frontend/src/pages/repairs/RepairsFeedPage.tsx @@ -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 (