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:
@@ -51,9 +51,22 @@ export interface MarkerSummary {
|
||||
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 {
|
||||
id: number;
|
||||
vehicle_id: number;
|
||||
/** Сводка по машине (гос.номер / марка / модель / год). Backend attaches её
|
||||
в get_inspection_by_id_with_relations, чтобы карточка осмотра могла
|
||||
показать машину без второго запроса. */
|
||||
vehicle?: VehicleBrief | null;
|
||||
type: InspectionType;
|
||||
performed_by: number;
|
||||
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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getInspection,
|
||||
deleteInspection,
|
||||
patchInspection,
|
||||
type MarkerSummary,
|
||||
type PhotoSummary,
|
||||
} from "@/api/inspections";
|
||||
@@ -121,10 +122,28 @@ export default function InspectionReview() {
|
||||
{ kind: "photo"; id: number } | { kind: "tt"; ttId: string } | 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 (isError || !ins)
|
||||
return <div className="p-8 text-destructive">Осмотр не найден.</div>;
|
||||
|
||||
const notesDirty = (notesDraft ?? "") !== (ins.notes ?? "");
|
||||
|
||||
// Lookup photo by id для определения annotated_key.
|
||||
const photosById = new Map<number, typeof ins.photos[0]>();
|
||||
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>
|
||||
|
||||
{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">
|
||||
<div>
|
||||
<span className="font-semibold">Тип: </span>
|
||||
@@ -184,10 +225,43 @@ export default function InspectionReview() {
|
||||
{new Date(ins.finished_at).toLocaleString("ru-RU")}
|
||||
</div>
|
||||
)}
|
||||
{ins.notes && (
|
||||
{ins.mileage != null && (
|
||||
<div>
|
||||
<span className="font-semibold">Заметки: </span>
|
||||
{ins.notes}
|
||||
<span className="font-semibold">Пробег: </span>
|
||||
{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>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user