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;
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user