Задача CRM 4439. Плитка в меню действий → поиск по госномеру → список ремонтов машины (дата, госномер, модель, пробег, состав работ) → существующая карточка ремонта. Вход в историю есть и прямо из карточки авто, там же блок последних ремонтов. Что стоило внимания: - поиск ищет и по машинам, выведенным из парка (их ремонты никуда не делись, и в осмотрах такая машина видна), но не по проданным — парк ими не занимается. Текст пустого результата говорит ровно это: механик с опечаткой в номере делает вывод по этой строке, и она обязана совпадать с поведением бэкенда; - сбой сети отличим от «ничего не нашли», и под ошибкой не остаётся выдача прошлого запроса — иначе можно тапнуть и уехать в историю чужой машины; - «Назад» возвращает туда, откуда пришли (лента, история машины, карточка авто), а не в ленту всего парка. Прямая ссылка при пустом стеке не запирает экран; - форма правки больше не оставляет лишнюю запись в стеке: до этого «Назад» из карточки ремонта после «Отмены» возвращал обратно в форму правки; - история и блок в карточке авто запрашивают ленту без фото — превью там не рисуется, а бэкенд ради него лазил в хранилище на каждую строку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
368 lines
17 KiB
TypeScript
368 lines
17 KiB
TypeScript
/**
|
|
* Карточка ремонта (PWA): read-only просмотр после клика по строке ленты.
|
|
*
|
|
* Минимальная версия для возможности «открыть и посмотреть». Полноценный
|
|
* вариант с lightbox, копированием госномера, обратным счётчиком и
|
|
* редактированием в 24h-окне — в PR-9 по плану.
|
|
*
|
|
* Endpoint: GET /api/v1/mechanic/repairs/{id}
|
|
*/
|
|
import { useEffect, useMemo, useState } from "react";
|
|
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));
|
|
}
|
|
|
|
function fmtDateTime(iso: string): string {
|
|
const d = new Date(iso);
|
|
return new Intl.DateTimeFormat("ru-RU", {
|
|
day: "2-digit", month: "long", year: "numeric",
|
|
hour: "2-digit", minute: "2-digit",
|
|
}).format(d);
|
|
}
|
|
|
|
export default function RepairDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
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());
|
|
|
|
// Тикер для обратного счётчика «Осталось X мин». Раз в 30 секунд хватит —
|
|
// окно правки 1 час, точность до минуты допустима.
|
|
useEffect(() => {
|
|
const t = setInterval(() => setNow(Date.now()), 30_000);
|
|
return () => clearInterval(t);
|
|
}, []);
|
|
|
|
const editRemaining = useMemo(() => {
|
|
if (!data?.can_edit || !data.edit_until) return null;
|
|
const until = new Date(data.edit_until).getTime();
|
|
const ms = until - now;
|
|
if (ms <= 0) return null;
|
|
const minutes = Math.floor(ms / 60_000);
|
|
if (minutes < 1) return "меньше минуты";
|
|
if (minutes < 60) return `${minutes} мин`;
|
|
const h = Math.floor(minutes / 60);
|
|
const m = minutes - h * 60;
|
|
return `${h} ч ${m} мин`;
|
|
}, [data, now]);
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
setLoading(true);
|
|
getRepairDetail(Number(id))
|
|
.then((r) => { setData(r); setError(null); })
|
|
.catch((e: Error) => setError(e.message || "Не удалось загрузить ремонт"))
|
|
.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={goBack}
|
|
className="rounded-full p-2 hover:bg-accent"
|
|
aria-label="Назад"
|
|
>
|
|
<ChevronLeft className="h-5 w-5" />
|
|
</button>
|
|
<div className="flex-1 min-w-0">
|
|
<h1 className="text-base font-semibold truncate">
|
|
{data ? `Ремонт #${data.id}` : "Ремонт"}
|
|
</h1>
|
|
{data && (
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{fmtDateTime(data.created_at)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="p-4 space-y-5 pb-12">
|
|
{loading && (
|
|
<div className="text-sm text-muted-foreground text-center py-12">
|
|
Загрузка…
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<div className="text-sm text-destructive bg-destructive/10 rounded-md p-3">
|
|
{error}
|
|
</div>
|
|
)}
|
|
{data && (
|
|
<>
|
|
{/* Кнопка правки в 1-часовом окне (У49). */}
|
|
{data.can_edit && editRemaining && (
|
|
<button
|
|
onClick={() => navigate(`/repairs/${data.id}/edit`)}
|
|
className="w-full flex items-center justify-between gap-2 rounded-lg border-2 border-amber-300 bg-amber-50 px-4 py-3 hover:bg-amber-100 transition-colors"
|
|
>
|
|
<span className="flex items-center gap-2 text-sm font-semibold text-amber-900">
|
|
<Pencil className="h-4 w-4" />
|
|
Изменить ремонт
|
|
</span>
|
|
<span className="text-xs font-mono text-amber-800">
|
|
осталось {editRemaining}
|
|
</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Шапка: машина + водитель + механик */}
|
|
<section className="rounded-lg border bg-card p-4 space-y-2">
|
|
<div className="text-xl font-bold font-mono">
|
|
{data.car_plate || "—"}
|
|
</div>
|
|
{(data.car_make || data.car_model) && (
|
|
<div className="text-sm text-muted-foreground">
|
|
{data.car_make} {data.car_model}
|
|
</div>
|
|
)}
|
|
<div className="pt-2 border-t text-sm space-y-1">
|
|
{data.driver_name && (
|
|
<div>
|
|
<span className="text-muted-foreground">Водитель: </span>
|
|
{data.driver_name}
|
|
</div>
|
|
)}
|
|
{data.mechanics && data.mechanics.length > 0 ? (
|
|
<div>
|
|
<span className="text-muted-foreground">
|
|
{data.mechanics.length > 1 ? "Механики: " : "Механик: "}
|
|
</span>
|
|
<span className="inline-flex flex-wrap gap-1.5 align-top">
|
|
{data.mechanics.map((m) => (
|
|
<span
|
|
key={m.id}
|
|
className="inline-flex items-center rounded-full border bg-muted/50 px-2.5 py-0.5 text-xs"
|
|
>
|
|
{m.name}
|
|
</span>
|
|
))}
|
|
</span>
|
|
{data.mechanics.length > 1 && (
|
|
<div className="text-xs text-muted-foreground mt-1">
|
|
Делёж поровну: {fmt(data.total_sum / data.mechanics.length)} ₽ на каждого
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<span className="text-muted-foreground">Механик: </span>
|
|
{data.mechanic_name || "—"}
|
|
</div>
|
|
)}
|
|
{data.mileage != null && (
|
|
<div>
|
|
<span className="text-muted-foreground">Пробег: </span>
|
|
<span className="font-mono">{fmt(data.mileage)} км</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Фото */}
|
|
{data.photos.length > 0 && (
|
|
<section>
|
|
<div className="text-sm font-semibold mb-2">
|
|
Фото · {data.photos.length}
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{data.photos.map((p) => (
|
|
<a
|
|
key={p.photo_uuid}
|
|
href={p.orig_url ?? "#"}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="block aspect-square overflow-hidden rounded-md border bg-muted"
|
|
>
|
|
{p.thumb_url && (
|
|
<img
|
|
src={p.thumb_url}
|
|
alt=""
|
|
loading="lazy"
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
)}
|
|
</a>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Работы */}
|
|
<section className="rounded-lg border bg-card overflow-hidden">
|
|
<div className="px-4 py-3 border-b bg-muted/40">
|
|
<div className="text-sm font-semibold">
|
|
Работы · {data.works.length}
|
|
</div>
|
|
</div>
|
|
<div className="divide-y">
|
|
{data.works.map((w, i) => (
|
|
<div key={i} className="px-4 py-3 flex items-start justify-between gap-3">
|
|
<div className="text-sm flex-1 min-w-0">
|
|
<div>
|
|
{w.name}
|
|
{w.manual_override && (
|
|
<span title="Цена изменена вручную" className="ml-2 text-amber-700">
|
|
✎
|
|
</span>
|
|
)}
|
|
</div>
|
|
{w.manual_override && w.override_comment && (
|
|
<div className="mt-1 text-xs text-amber-800 bg-amber-50 border-l-2 border-amber-300 pl-2 py-0.5 leading-snug">
|
|
{w.override_comment}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="text-sm font-mono whitespace-nowrap text-muted-foreground shrink-0 mt-0.5">
|
|
{fmt(w.price_applied)} ₽
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div className="px-4 py-3 flex items-baseline justify-between bg-muted/30">
|
|
<div className="text-sm font-semibold">Итого</div>
|
|
<div className="text-base font-mono font-bold">
|
|
{fmt(data.total_sum)} ₽
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Запчасти */}
|
|
{data.parts.length > 0 && (
|
|
<section className="rounded-lg border bg-card overflow-hidden">
|
|
<div className="px-4 py-3 border-b bg-muted/40">
|
|
<div className="text-sm font-semibold">
|
|
Запчасти · {data.parts.length}
|
|
</div>
|
|
</div>
|
|
<div className="divide-y">
|
|
{data.parts.map((p, i) => (
|
|
<div key={i} className="px-4 py-3 flex items-baseline justify-between gap-3">
|
|
<div className="text-sm">{p.name}</div>
|
|
<div className="text-sm font-mono whitespace-nowrap text-muted-foreground">
|
|
{p.qty} шт
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Замена масла */}
|
|
{data.oil_change && (
|
|
<section className="rounded-lg border bg-card overflow-hidden">
|
|
<div className="px-4 py-3 border-b bg-muted/40">
|
|
<div className="text-sm font-semibold">Замена масла</div>
|
|
</div>
|
|
<div className="p-4">
|
|
<div className="flex gap-3 items-start">
|
|
{data.oil_change.sticker_thumb_url && (
|
|
<a
|
|
href={data.oil_change.sticker_orig_url ?? "#"}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="flex-shrink-0"
|
|
>
|
|
<img
|
|
src={data.oil_change.sticker_thumb_url}
|
|
alt="Стикер замены масла"
|
|
loading="lazy"
|
|
className="w-24 h-24 object-cover rounded-md border bg-muted"
|
|
/>
|
|
</a>
|
|
)}
|
|
<div className="flex-1 text-sm space-y-1">
|
|
{data.oil_change.mileage_at_change != null && (
|
|
<div>
|
|
<span className="text-muted-foreground">Пробег при замене: </span>
|
|
<span className="font-mono">{fmt(data.oil_change.mileage_at_change)} км</span>
|
|
</div>
|
|
)}
|
|
{data.oil_change.next_in_km != null && (
|
|
<div>
|
|
<span className="text-muted-foreground">Следующая через: </span>
|
|
<span className="font-mono">{fmt(data.oil_change.next_in_km)} км</span>
|
|
</div>
|
|
)}
|
|
{data.oil_change.has_sticker && !data.oil_change.sticker_thumb_url && (
|
|
<div className="text-xs text-muted-foreground pt-1">
|
|
✓ фото стикера загружено
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{data.edited_in_crm && (
|
|
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md p-3">
|
|
Этот ремонт был отредактирован в CRM. Кнопка «Редактировать»
|
|
в PWA для него недоступна.
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|