inspection-review: карточка машины + редактируемые заметки

1. Сверху на /inspections/<id> добавлена кликабельная карточка машины:
   гос.номер крупно + марка/модель/год + VIN. Использует новое поле
   ins.vehicle, которое backend теперь возвращает в InspectionDetail
   (см. PremiumCRM коммит a9c94d0). Клик ведёт на /vehicles/<id>.

2. Блок «Заметки механика» — textarea с локальным draft'ом и кнопкой
   «Сохранить» (видна только если draft отличается от ins.notes). Под
   капотом — PATCH /api/v1/mechanic/inspections/<id> с {notes}.
   TG-импортированные осмотры из «Подготовка авто» уже приезжают с
   заполненными notes (caption сообщения становится телом заметки), и
   теперь механик может дополнить/исправить их прямо в карточке.

3. Бонус: добавлен показ пробега (mileage) в первой info-карте.
This commit is contained in:
2026-05-20 13:34:10 +10:00
parent c7c989d886
commit d208838bb3
2 changed files with 91 additions and 4 deletions
@@ -51,9 +51,22 @@ export interface MarkerSummary {
created_at: string; created_at: string;
} }
export interface VehicleBrief {
id: number;
license_plate: string;
vin?: string | null;
make?: string | null;
model?: string | null;
year?: number | null;
}
export interface InspectionDetail { export interface InspectionDetail {
id: number; id: number;
vehicle_id: number; vehicle_id: number;
/** Сводка по машине (гос.номер / марка / модель / год). Backend attaches её
в get_inspection_by_id_with_relations, чтобы карточка осмотра могла
показать машину без второго запроса. */
vehicle?: VehicleBrief | null;
type: InspectionType; type: InspectionType;
performed_by: number; performed_by: number;
driver_id?: number | null; driver_id?: number | null;
@@ -1,10 +1,11 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
getInspection, getInspection,
deleteInspection, deleteInspection,
patchInspection,
type MarkerSummary, type MarkerSummary,
type PhotoSummary, type PhotoSummary,
} from "@/api/inspections"; } from "@/api/inspections";
@@ -121,10 +122,28 @@ export default function InspectionReview() {
{ kind: "photo"; id: number } | { kind: "tt"; ttId: string } | null { kind: "photo"; id: number } | { kind: "tt"; ttId: string } | null
>(null); >(null);
// Локальный draft заметок — синхронизируется с ins.notes при загрузке/обновлении.
// Кнопка «Сохранить» появляется только если draft отличается от сохранённого.
const [notesDraft, setNotesDraft] = useState<string>("");
useEffect(() => {
if (ins?.notes !== undefined) setNotesDraft(ins.notes ?? "");
}, [ins?.notes]);
const notesMutation = useMutation({
mutationFn: (next: string) => patchInspection(insId, { notes: next }),
onSuccess: (updated) => {
toast.success("Заметка сохранена");
queryClient.setQueryData(["inspection", insId], updated);
},
onError: () => toast.error("Не удалось сохранить заметку"),
});
if (isLoading) return <div className="p-8 text-muted-foreground">Загружаю</div>; if (isLoading) return <div className="p-8 text-muted-foreground">Загружаю</div>;
if (isError || !ins) if (isError || !ins)
return <div className="p-8 text-destructive">Осмотр не найден.</div>; return <div className="p-8 text-destructive">Осмотр не найден.</div>;
const notesDirty = (notesDraft ?? "") !== (ins.notes ?? "");
// Lookup photo by id для определения annotated_key. // Lookup photo by id для определения annotated_key.
const photosById = new Map<number, typeof ins.photos[0]>(); const photosById = new Map<number, typeof ins.photos[0]>();
for (const p of ins.photos) photosById.set(p.id, p); for (const p of ins.photos) photosById.set(p.id, p);
@@ -159,6 +178,28 @@ export default function InspectionReview() {
<h1 className="text-xl font-semibold">Осмотр #{ins.id}</h1> <h1 className="text-xl font-semibold">Осмотр #{ins.id}</h1>
{ins.vehicle && (
<Card
className="p-4 text-sm space-y-1 cursor-pointer hover:bg-accent/40 transition-colors"
onClick={() => navigate(`/vehicles/${ins.vehicle?.id}`)}
>
<div className="text-lg font-semibold tracking-wide">
{ins.vehicle.license_plate}
</div>
<div className="text-muted-foreground">
{[ins.vehicle.make, ins.vehicle.model, ins.vehicle.year]
.filter(Boolean)
.join(" · ") || "—"}
</div>
{ins.vehicle.vin && (
<div className="text-xs text-muted-foreground">
<span className="font-semibold">VIN: </span>
{ins.vehicle.vin}
</div>
)}
</Card>
)}
<Card className="p-4 text-sm space-y-1"> <Card className="p-4 text-sm space-y-1">
<div> <div>
<span className="font-semibold">Тип: </span> <span className="font-semibold">Тип: </span>
@@ -184,10 +225,43 @@ export default function InspectionReview() {
{new Date(ins.finished_at).toLocaleString("ru-RU")} {new Date(ins.finished_at).toLocaleString("ru-RU")}
</div> </div>
)} )}
{ins.notes && ( {ins.mileage != null && (
<div> <div>
<span className="font-semibold">Заметки: </span> <span className="font-semibold">Пробег: </span>
{ins.notes} {ins.mileage.toLocaleString("ru-RU")} км
</div>
)}
</Card>
<Card className="p-4 space-y-2">
<h2 className="text-sm font-semibold text-muted-foreground">
Заметки механика
</h2>
<textarea
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
placeholder="Текст осмотра, замечания, рекомендации…"
rows={Math.max(3, Math.min(notesDraft.split("\n").length + 1, 12))}
className="w-full text-sm p-2 rounded border border-input bg-background resize-y focus:outline-none focus:ring-2 focus:ring-ring"
/>
{notesDirty && (
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => setNotesDraft(ins.notes ?? "")}
className="text-xs text-muted-foreground hover:text-foreground px-3 py-1"
disabled={notesMutation.isPending}
>
Отменить
</button>
<button
type="button"
onClick={() => notesMutation.mutate(notesDraft)}
disabled={notesMutation.isPending}
className="text-xs bg-primary text-primary-foreground rounded px-3 py-1 disabled:opacity-60"
>
{notesMutation.isPending ? "Сохраняю…" : "Сохранить"}
</button>
</div> </div>
)} )}
</Card> </Card>