wip(mechanic): inflight damage-flow + vendor scheme + PhotoAnnotate + TT views
Маркер «вот где остановилась mechanic-сессия» — не разбито по логическим коммитам,
зафиксировано целиком чтобы не потерять при отвлечении/чекаутах.
Modified:
- pages/InspectionEditor.tsx — расширение damage-flow внутри редактора
- pages/VehicleCard.tsx — vendor data + расширение карточки
- pages/InspectionReview.tsx — обновлённый review
- api/{auth,inspections,photos,vehicles}.ts — новые endpoints для TT + photo flow
- components/damage-flow/DamageClassifyDialog.tsx — multi-step UI
- components/vehicle-scheme/VendorVehicleScheme.tsx — SVG zone interaction
- data/damageVocabulary.ts — обновлённый словарь повреждений
- frontend index.html/manifest/icons — PWA-метаданные
New (untracked):
- components/damage-flow/PhotoAnnotateStep.tsx — новый шаг flow'а
- components/tt/ — vendor TT-state UI
- pages/TtStateDetail.tsx — экран осмотра вендора
- recon/findings/damage_model.md + damage_vocabulary.json + scheme/ — recon docs
Cleanup: удалены bash-garbage 'Last' и 'dict[str' из frontend/.
@@ -3,8 +3,16 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#2a2a2a" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Mechanic" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Premium Механик</title>
|
||||
</head>
|
||||
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 514 B |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 410 B |
|
After Width: | Height: | Size: 889 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -4,10 +4,11 @@
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#f5f1ea",
|
||||
"theme_color": "#2a2a2a",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#000000",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Home from "@/pages/Home";
|
||||
import VehicleCard from "@/pages/VehicleCard";
|
||||
import InspectionEditor from "@/pages/InspectionEditor";
|
||||
import InspectionReview from "@/pages/InspectionReview";
|
||||
import TtStateDetail from "@/pages/TtStateDetail";
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuth((s) => s.token);
|
||||
@@ -22,6 +23,7 @@ export default function App() {
|
||||
<Route path="/vehicles/:id" element={<RequireAuth><VehicleCard /></RequireAuth>} />
|
||||
<Route path="/inspections/:id/edit" element={<RequireAuth><InspectionEditor /></RequireAuth>} />
|
||||
<Route path="/inspections/:id" element={<RequireAuth><InspectionReview /></RequireAuth>} />
|
||||
<Route path="/tt-states/:id" element={<RequireAuth><TtStateDetail /></RequireAuth>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,8 @@ export async function login(input: LoginInput): Promise<LoginOutput> {
|
||||
const body = new URLSearchParams();
|
||||
body.set("username", input.username);
|
||||
body.set("password", input.password);
|
||||
// PWA — длинная сессия (30 дней) чтобы механики не вводили пароль каждые 8 ч.
|
||||
body.set("long_session", "true");
|
||||
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
@@ -42,7 +44,7 @@ export async function login2fa(input: TwoFactorInput): Promise<LoginOutput> {
|
||||
const res = await fetch("/api/auth/2fa", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
body: JSON.stringify({ ...input, long_session: true }),
|
||||
credentials: "include", // accept the trusted_device cookie on Set-Cookie
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface PhotoSummary {
|
||||
slot_index: number;
|
||||
storage_key: string;
|
||||
thumb_key?: string | null;
|
||||
annotated_key?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
status: string;
|
||||
@@ -37,6 +38,7 @@ export interface MarkerSummary {
|
||||
id: number;
|
||||
inspection_id: number;
|
||||
photo_id?: number | null;
|
||||
tt_image_id?: string | null;
|
||||
side?: string | null;
|
||||
x?: number | null;
|
||||
y?: number | null;
|
||||
@@ -103,4 +105,12 @@ export async function createMarker(
|
||||
.json<MarkerSummary>();
|
||||
}
|
||||
|
||||
export async function deleteInspection(inspectionId: number): Promise<void> {
|
||||
await api.delete(`inspections/${inspectionId}`);
|
||||
}
|
||||
|
||||
export async function deleteMarker(markerId: number): Promise<void> {
|
||||
await api.delete(`markers/${markerId}`);
|
||||
}
|
||||
|
||||
export type { InspectionSummary };
|
||||
|
||||
@@ -14,12 +14,27 @@ export interface PhotoConfirmResponse {
|
||||
slot_index: number;
|
||||
storage_key: string;
|
||||
thumb_key?: string | null;
|
||||
annotated_key?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
status: string;
|
||||
taken_at: string;
|
||||
}
|
||||
|
||||
export async function uploadPhotoAnnotation(
|
||||
inspectionId: number,
|
||||
photoId: number,
|
||||
blob: Blob,
|
||||
): Promise<PhotoConfirmResponse> {
|
||||
const form = new FormData();
|
||||
form.append("file", blob, "annotation.jpg");
|
||||
return api
|
||||
.post(`inspections/${inspectionId}/photos/${photoId}/annotation`, {
|
||||
body: form,
|
||||
})
|
||||
.json<PhotoConfirmResponse>();
|
||||
}
|
||||
|
||||
export async function requestUploadUrl(
|
||||
inspectionId: number,
|
||||
args: { side: string; slot_index: number; content_type: string; size: number }
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface VehicleSummary {
|
||||
|
||||
export interface VehicleDetail extends VehicleSummary {
|
||||
recent_inspections: InspectionSummary[];
|
||||
starline_mileage?: number | null;
|
||||
starline_mileage_at?: string | null;
|
||||
}
|
||||
|
||||
export interface InspectionSummary {
|
||||
@@ -36,3 +38,74 @@ export async function listVehicles(q?: string): Promise<VehicleSummary[]> {
|
||||
export async function getVehicle(id: number): Promise<VehicleDetail> {
|
||||
return api.get(`vehicles/${id}`).json<VehicleDetail>();
|
||||
}
|
||||
|
||||
// ── TT-Control vendor archive (Element Mechanic) ───────────────────────────
|
||||
|
||||
export interface TtStateSummary {
|
||||
id: number;
|
||||
vendor_state_id: string;
|
||||
unix_time: number | null;
|
||||
mechanic_name: string | null;
|
||||
mileage: number | null;
|
||||
photos_count: number;
|
||||
damages_count: number;
|
||||
}
|
||||
|
||||
export interface TtHistory {
|
||||
vendor_vehicle_id: string | null;
|
||||
plate?: string | null;
|
||||
brand?: string | null;
|
||||
model?: string | null;
|
||||
states: TtStateSummary[];
|
||||
}
|
||||
|
||||
export interface TtPhotoRef {
|
||||
image_id: string;
|
||||
image_with_lines_id: string | null;
|
||||
unix_time: number | null;
|
||||
guid: string | null;
|
||||
}
|
||||
|
||||
export interface TtDamage {
|
||||
damage_type_id: number | null;
|
||||
degree: number | null;
|
||||
points: { x: number; y: number }[];
|
||||
guid: string | null;
|
||||
unix_time: number | null;
|
||||
}
|
||||
|
||||
export interface TtSidePhoto {
|
||||
image_id: string;
|
||||
miniature_id: string | null;
|
||||
photo_type: number | null;
|
||||
}
|
||||
|
||||
export interface TtStateDetail {
|
||||
id: number;
|
||||
vendor_state_id: string;
|
||||
unix_time: number | null;
|
||||
mechanic_name: string | null;
|
||||
mileage: number | null;
|
||||
side_photos: TtSidePhoto[];
|
||||
photos_by_zone: Record<string, TtPhotoRef[]>;
|
||||
damages_by_zone: Record<string, TtDamage[]>;
|
||||
}
|
||||
|
||||
export async function getTtHistory(vehicleId: number): Promise<TtHistory> {
|
||||
return api.get(`vehicles/${vehicleId}/tt-history`).json<TtHistory>();
|
||||
}
|
||||
|
||||
export async function getTtState(stateId: number): Promise<TtStateDetail> {
|
||||
return api.get(`tt-states/${stateId}`).json<TtStateDetail>();
|
||||
}
|
||||
|
||||
export async function deleteTtState(stateId: number): Promise<void> {
|
||||
await api.delete(`tt-states/${stateId}`);
|
||||
}
|
||||
|
||||
export function ttImageUrl(imageId: string, withLines: boolean = false): string {
|
||||
// tt-image endpoint без auth-guard'а (vendor сам публично отдаёт эти JPEG'и),
|
||||
// поэтому подходит для прямого <img src=...>.
|
||||
const base = import.meta.env.VITE_API_BASE ?? "/api/v1/mechanic";
|
||||
return `${base}/tt-image/${imageId}${withLines ? "?lines=true" : ""}`;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { loadDamageVocabulary } from "@/data/damageVocabulary";
|
||||
import { loadDamageVocabulary, damageTypeRuToEnum } from "@/data/damageVocabulary";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -29,8 +29,12 @@ export function DamageClassifyDialog({ open, zoneId, zoneLabel, pointsCount, onC
|
||||
|
||||
// Salon zones (44=водительское сидение, 45=пассажирское, 46=задний диван) use salon_selectable; else exterior
|
||||
const isSalon = zoneId === 44 || zoneId === 45 || zoneId === 46;
|
||||
// Фильтруем типы для которых нет соответствия в backend DamageType enum —
|
||||
// иначе backend вернёт 422 на createMarker.
|
||||
const list = vocab
|
||||
? (isSalon ? vocab.salon_selectable : vocab.exterior_selectable)
|
||||
? (isSalon ? vocab.salon_selectable : vocab.exterior_selectable).filter(
|
||||
(dt) => damageTypeRuToEnum(dt) != null,
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
photoFile: File;
|
||||
zoneLabel: string;
|
||||
/** Called when user submits. annotated=null если "без линий". */
|
||||
onSubmit: (annotated: Blob | null) => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface Stroke {
|
||||
pts: { x: number; y: number }[]; // in image-space pixels
|
||||
}
|
||||
|
||||
/** Pen-on-photo step: показывает только что снятое фото, даёт обвести
|
||||
* пальцем красным. Flatten в JPEG при submit. Координаты strokes в native
|
||||
* пикселях фото — canvas resize'нут на тот же размер. */
|
||||
export function PhotoAnnotateStep({ photoFile, zoneLabel, onSubmit, onCancel }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
const [strokes, setStrokes] = useState<Stroke[]>([]);
|
||||
const currentStroke = useRef<Stroke | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Load image once
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(photoFile);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
imgRef.current = img;
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
}
|
||||
setImgLoaded(true);
|
||||
URL.revokeObjectURL(url);
|
||||
redraw([]);
|
||||
};
|
||||
img.onerror = () => URL.revokeObjectURL(url);
|
||||
img.src = url;
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [photoFile]);
|
||||
|
||||
function redraw(allStrokes: Stroke[]) {
|
||||
const canvas = canvasRef.current;
|
||||
const img = imgRef.current;
|
||||
if (!canvas || !img) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
// Linewidth scaled to image — на 4К фото 4px тонко смотрится
|
||||
const lw = Math.max(6, Math.round(canvas.width / 250));
|
||||
ctx.strokeStyle = "rgb(220, 38, 38)";
|
||||
ctx.lineWidth = lw;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
for (const s of allStrokes) {
|
||||
if (s.pts.length < 2) {
|
||||
// single tap → dot
|
||||
if (s.pts.length === 1) {
|
||||
ctx.fillStyle = "rgb(220, 38, 38)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.pts[0].x, s.pts[0].y, lw / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.pts[0].x, s.pts[0].y);
|
||||
for (let i = 1; i < s.pts.length; i++) {
|
||||
ctx.lineTo(s.pts[i].x, s.pts[i].y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-draw whenever strokes change
|
||||
useEffect(() => {
|
||||
if (imgLoaded) redraw(strokes);
|
||||
}, [strokes, imgLoaded]);
|
||||
|
||||
function pointFromEvent(e: React.PointerEvent<HTMLCanvasElement>): { x: number; y: number } | null {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return null;
|
||||
// CSS px → canvas px
|
||||
const x = ((e.clientX - rect.left) / rect.width) * canvas.width;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * canvas.height;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function onPointerDown(e: React.PointerEvent<HTMLCanvasElement>) {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
|
||||
const pt = pointFromEvent(e);
|
||||
if (!pt) return;
|
||||
currentStroke.current = { pts: [pt] };
|
||||
// Immediate visual feedback: draw a partial stroke without setState
|
||||
drawPartialStroke(currentStroke.current);
|
||||
}
|
||||
|
||||
function onPointerMove(e: React.PointerEvent<HTMLCanvasElement>) {
|
||||
if (!currentStroke.current) return;
|
||||
const pt = pointFromEvent(e);
|
||||
if (!pt) return;
|
||||
currentStroke.current.pts.push(pt);
|
||||
drawPartialStroke(currentStroke.current);
|
||||
}
|
||||
|
||||
function onPointerUp(e: React.PointerEvent<HTMLCanvasElement>) {
|
||||
if (!currentStroke.current) return;
|
||||
try {
|
||||
(e.target as HTMLCanvasElement).releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// ignore — pointer wasn't captured
|
||||
}
|
||||
const finished = currentStroke.current;
|
||||
currentStroke.current = null;
|
||||
if (finished.pts.length > 0) {
|
||||
setStrokes((s) => [...s, finished]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Avoid re-running full redraw on every move — just append last segment. */
|
||||
function drawPartialStroke(s: Stroke) {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || s.pts.length < 2) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const lw = Math.max(6, Math.round(canvas.width / 250));
|
||||
ctx.strokeStyle = "rgb(220, 38, 38)";
|
||||
ctx.lineWidth = lw;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
const a = s.pts[s.pts.length - 2];
|
||||
const b = s.pts[s.pts.length - 1];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setStrokes([]);
|
||||
}
|
||||
|
||||
function undoLast() {
|
||||
setStrokes((s) => s.slice(0, -1));
|
||||
}
|
||||
|
||||
async function finishWithAnnotation() {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, "image/jpeg", 0.85)
|
||||
);
|
||||
await onSubmit(blob);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function finishWithoutAnnotation() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(null);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-background flex flex-col">
|
||||
<header className="p-4 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Обведите повреждение</div>
|
||||
<h2 className="text-lg font-semibold capitalize">{zoneLabel}</h2>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={undoLast}
|
||||
disabled={strokes.length === 0 || submitting}
|
||||
className="text-sm px-3 py-1 border rounded-md disabled:opacity-40"
|
||||
title="Отменить последний штрих"
|
||||
>
|
||||
↶
|
||||
</button>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
disabled={strokes.length === 0 || submitting}
|
||||
className="text-sm px-3 py-1 border rounded-md disabled:opacity-40"
|
||||
title="Очистить"
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-auto p-2 flex items-center justify-center bg-black">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
className="max-w-full max-h-full block"
|
||||
style={{ touchAction: "none", cursor: "crosshair" }}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<footer className="border-t p-3 flex gap-2 bg-card">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className="flex-none"
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={finishWithoutAnnotation}
|
||||
disabled={submitting}
|
||||
className="flex-1"
|
||||
>
|
||||
Без линий
|
||||
</Button>
|
||||
<Button
|
||||
onClick={finishWithAnnotation}
|
||||
disabled={submitting || strokes.length === 0}
|
||||
className="flex-1"
|
||||
>
|
||||
{submitting ? "Сохраняю…" : "Готово"}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface DamageOverlay {
|
||||
zoneId: number;
|
||||
points: Point[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
damagedZoneIds: number[];
|
||||
/** Координаты точек повреждений в композитной SVG-системе (827×1209). */
|
||||
damages?: DamageOverlay[];
|
||||
onZoneClick?: (zoneId: number) => void;
|
||||
}
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const POINT_RADIUS = 9;
|
||||
|
||||
/** Композитная SVG-схема экстерьера машины с подсветкой зон + точками
|
||||
* повреждений. Координаты точек в системе 827×1209 (та же что у vendor). */
|
||||
export function TtDamageMap({ damagedZoneIds, damages, onZoneClick }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [svgText, setSvgText] = useState<string | null>(null);
|
||||
const damagedSet = new Set(damagedZoneIds.map(String));
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/scheme/exterior.svg")
|
||||
.then((r) => r.text())
|
||||
.then((text) => {
|
||||
if (cancelled) return;
|
||||
let mut = text;
|
||||
mut = mut.replace(/<script[\s\S]*?<\/script>/gi, "");
|
||||
mut = mut.replace(
|
||||
/(<svg\b[^>]*?)\s(?:width|height)\s*=\s*"[^"]*"/gi,
|
||||
"$1"
|
||||
);
|
||||
mut = mut.replace(
|
||||
/<svg\b/i,
|
||||
`<svg width="100%" height="100%" preserveAspectRatio="xMidYMid meet"`
|
||||
);
|
||||
setSvgText(mut);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Tinting + cursors
|
||||
useEffect(() => {
|
||||
if (!svgText) return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
||||
if (!svg) return;
|
||||
|
||||
svg.querySelectorAll("path[class]").forEach((p) => {
|
||||
const cls = p.getAttribute("class") ?? "";
|
||||
if (damagedSet.has(cls)) {
|
||||
p.setAttribute("fill", "rgba(239, 68, 68, 0.35)");
|
||||
p.setAttribute("stroke", "rgb(239, 68, 68)");
|
||||
p.setAttribute("stroke-width", "2");
|
||||
(p as SVGElement).style.cursor = onZoneClick ? "pointer" : "default";
|
||||
} else {
|
||||
p.setAttribute("fill", "rgba(0,0,0,0.04)");
|
||||
p.setAttribute("stroke", "rgba(0,0,0,0.25)");
|
||||
p.setAttribute("stroke-width", "0.5");
|
||||
(p as SVGElement).style.cursor = "default";
|
||||
}
|
||||
});
|
||||
svg.querySelectorAll("path:not([class])").forEach((p) => {
|
||||
(p as SVGElement).style.opacity = "0.4";
|
||||
(p as SVGElement).style.pointerEvents = "none";
|
||||
});
|
||||
}, [svgText, damagedSet, onZoneClick]);
|
||||
|
||||
// Point overlay — рисуем поверх SVG в его user-space (827×1209) чтобы
|
||||
// точки идеально совмещались с подсвеченными зонами при любом масштабе.
|
||||
useEffect(() => {
|
||||
if (!svgText) return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
||||
if (!svg) return;
|
||||
|
||||
const existing = svg.querySelector("g[data-tt-damage-points]");
|
||||
if (existing) existing.remove();
|
||||
|
||||
if (!damages || damages.length === 0) return;
|
||||
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
group.setAttribute("data-tt-damage-points", "1");
|
||||
|
||||
let counter = 1;
|
||||
for (const dmg of damages) {
|
||||
for (const pt of dmg.points || []) {
|
||||
if (
|
||||
typeof pt.x !== "number" ||
|
||||
typeof pt.y !== "number" ||
|
||||
!Number.isFinite(pt.x) ||
|
||||
!Number.isFinite(pt.y)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const circle = document.createElementNS(SVG_NS, "circle");
|
||||
circle.setAttribute("cx", String(pt.x));
|
||||
circle.setAttribute("cy", String(pt.y));
|
||||
circle.setAttribute("r", String(POINT_RADIUS));
|
||||
circle.setAttribute("fill", "rgb(220, 38, 38)");
|
||||
circle.setAttribute("stroke", "white");
|
||||
circle.setAttribute("stroke-width", "2");
|
||||
(circle as SVGElement).style.pointerEvents = "none";
|
||||
group.appendChild(circle);
|
||||
|
||||
const text = document.createElementNS(SVG_NS, "text");
|
||||
text.setAttribute("x", String(pt.x));
|
||||
text.setAttribute("y", String(pt.y));
|
||||
text.setAttribute("text-anchor", "middle");
|
||||
text.setAttribute("dominant-baseline", "central");
|
||||
text.setAttribute("fill", "white");
|
||||
text.setAttribute("font-size", "11");
|
||||
text.setAttribute("font-weight", "bold");
|
||||
(text as SVGElement).style.pointerEvents = "none";
|
||||
(text as SVGElement).style.userSelect = "none";
|
||||
text.textContent = String(counter);
|
||||
group.appendChild(text);
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
svg.appendChild(group);
|
||||
}, [svgText, damages]);
|
||||
|
||||
function handleClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (!onZoneClick) return;
|
||||
const target = e.target as Element;
|
||||
const cls = target.getAttribute?.("class");
|
||||
if (cls && damagedSet.has(cls)) {
|
||||
const zoneId = Number(cls);
|
||||
if (Number.isFinite(zoneId)) onZoneClick(zoneId);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={handleClick}
|
||||
className="w-full aspect-[827/1209] max-h-[60vh] mx-auto bg-card rounded-lg overflow-hidden"
|
||||
style={{ touchAction: "manipulation" }}
|
||||
dangerouslySetInnerHTML={svgText ? { __html: svgText } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,17 @@ export interface SchemeMarker {
|
||||
damage_type?: string | null;
|
||||
severity?: string | null;
|
||||
resolved: boolean;
|
||||
/** Координаты точек повреждения в композитной SVG-системе (827×1209). */
|
||||
polygon?: { x: number; y: number }[] | null;
|
||||
}
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const POINT_RADIUS = 9;
|
||||
const POINT_FILL_ACTIVE = "rgb(220, 38, 38)";
|
||||
const POINT_FILL_RESOLVED = "rgb(107, 114, 128)";
|
||||
const COMPOSITE_W = 827;
|
||||
const COMPOSITE_H = 1209;
|
||||
|
||||
interface Props {
|
||||
markers: SchemeMarker[];
|
||||
onZoneTap?: (zoneId: number) => void;
|
||||
@@ -110,6 +119,69 @@ export function VendorVehicleScheme({
|
||||
});
|
||||
}, [svgText, zoneStats, hoverZone, editable]);
|
||||
|
||||
// Overlay точек повреждений (multi-point координаты в композитной 827×1209)
|
||||
useEffect(() => {
|
||||
if (!svgText) return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
||||
if (!svg) return;
|
||||
|
||||
// Удаляем предыдущий overlay
|
||||
const existing = svg.querySelector("g[data-damage-points]");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
group.setAttribute("data-damage-points", "1");
|
||||
|
||||
let counter = 1;
|
||||
for (const m of markers) {
|
||||
const pts = m.polygon || [];
|
||||
if (pts.length === 0) continue;
|
||||
const fill = m.resolved ? POINT_FILL_RESOLVED : POINT_FILL_ACTIVE;
|
||||
|
||||
for (const pt of pts) {
|
||||
if (
|
||||
typeof pt.x !== "number" ||
|
||||
typeof pt.y !== "number" ||
|
||||
!Number.isFinite(pt.x) ||
|
||||
!Number.isFinite(pt.y)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// Polygon хранится в normalised 0..1 (см. ZoneDetailView). SVG viewBox
|
||||
// в композитной 827×1209, поэтому умножаем перед отрисовкой.
|
||||
const cx = pt.x * COMPOSITE_W;
|
||||
const cy = pt.y * COMPOSITE_H;
|
||||
const circle = document.createElementNS(SVG_NS, "circle");
|
||||
circle.setAttribute("cx", String(cx));
|
||||
circle.setAttribute("cy", String(cy));
|
||||
circle.setAttribute("r", String(POINT_RADIUS));
|
||||
circle.setAttribute("fill", fill);
|
||||
circle.setAttribute("stroke", "white");
|
||||
circle.setAttribute("stroke-width", "2");
|
||||
(circle as SVGElement).style.pointerEvents = "none";
|
||||
group.appendChild(circle);
|
||||
|
||||
const text = document.createElementNS(SVG_NS, "text");
|
||||
text.setAttribute("x", String(cx));
|
||||
text.setAttribute("y", String(cy));
|
||||
text.setAttribute("text-anchor", "middle");
|
||||
text.setAttribute("dominant-baseline", "central");
|
||||
text.setAttribute("fill", "white");
|
||||
text.setAttribute("font-size", "11");
|
||||
text.setAttribute("font-weight", "bold");
|
||||
(text as SVGElement).style.pointerEvents = "none";
|
||||
(text as SVGElement).style.userSelect = "none";
|
||||
text.textContent = String(counter);
|
||||
group.appendChild(text);
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
svg.appendChild(group);
|
||||
}, [svgText, markers]);
|
||||
|
||||
// Walk up from event.target to find a path[class="N"] (numeric zone id)
|
||||
function zoneFromEvent(e: React.MouseEvent | React.TouchEvent): number | null {
|
||||
let node = e.target as Element | null;
|
||||
|
||||
@@ -24,3 +24,42 @@ export async function loadDamageVocabulary(): Promise<DamageVocabulary> {
|
||||
};
|
||||
return cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping русского названия из damage_vocabulary.json в DamageType enum
|
||||
* нашего backend'а (см. mechanic_schemas.DamageType Literal).
|
||||
* Возвращает null для типов которым нет точного соответствия —
|
||||
* вызывающий код должен такие отфильтровать из UI.
|
||||
*/
|
||||
export const DAMAGE_RU_TO_ENUM: Record<string, string | null> = {
|
||||
"Вмятина": "dent",
|
||||
"Царапина": "scratch",
|
||||
"Трещина": "crack",
|
||||
"Повреждено": "damaged",
|
||||
"Скол": "chip",
|
||||
"Отсутствует": "missing",
|
||||
"Прожжено": "burn",
|
||||
"Погнуто": "bent",
|
||||
"Требуется мойка": null,
|
||||
"Не работает": "not-working",
|
||||
"Порез": "cut",
|
||||
"Прокол": "puncture",
|
||||
"Порвано": "torn",
|
||||
"Пятна": "stain",
|
||||
"Грязный салон": "stain",
|
||||
"Запах табака": null,
|
||||
"Повреждение салонных ковриков": "damaged",
|
||||
"Повреждение обшивки дверей": "damaged",
|
||||
"Сломанные ручки": "damaged",
|
||||
"Установлено": "removed",
|
||||
"Снято": "removed",
|
||||
"Контроль": null,
|
||||
"Требуется химчистка": "stain",
|
||||
"Затертость": "scuff",
|
||||
"Грыжа": "bulge",
|
||||
"Штат": null,
|
||||
};
|
||||
|
||||
export function damageTypeRuToEnum(ru: string): string | null {
|
||||
return DAMAGE_RU_TO_ENUM[ru] ?? null;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
getInspection,
|
||||
patchInspection,
|
||||
createMarker,
|
||||
deleteMarker,
|
||||
type MarkerSummary,
|
||||
} from "@/api/inspections";
|
||||
import { uploadPhoto } from "@/api/photos";
|
||||
import { uploadPhoto, uploadPhotoAnnotation } from "@/api/photos";
|
||||
import { PhotoSlot } from "@/components/camera/PhotoSlot";
|
||||
import { CameraCapture } from "@/components/camera/CameraCapture";
|
||||
import { VendorVehicleScheme } from "@/components/vehicle-scheme/VendorVehicleScheme";
|
||||
@@ -16,12 +17,15 @@ import { TireWearDialog } from "@/components/inspection/TireWearDialog";
|
||||
import { ZoneConfirmDialog } from "@/components/damage-flow/ZoneConfirmDialog";
|
||||
import { ZoneDetailView } from "@/components/damage-flow/ZoneDetailView";
|
||||
import { DamageClassifyDialog } from "@/components/damage-flow/DamageClassifyDialog";
|
||||
import { PhotoAnnotateStep } from "@/components/damage-flow/PhotoAnnotateStep";
|
||||
import { AuthImg } from "@/components/AuthImg";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { loadZoneLabels } from "@/data/zoneLabels";
|
||||
import { damageTypeRuToEnum } from "@/data/damageVocabulary";
|
||||
import { ttImageUrl } from "@/api/vehicles";
|
||||
|
||||
const SLOTS: { side: string; label: string }[] = [
|
||||
const SLOTS_GENERAL: { side: string; label: string }[] = [
|
||||
{ side: "front", label: "Перед" },
|
||||
{ side: "rear", label: "Зад" },
|
||||
{ side: "left", label: "Левый бок" },
|
||||
@@ -32,6 +36,15 @@ const SLOTS: { side: string; label: string }[] = [
|
||||
{ side: "free", label: "Свободное" },
|
||||
];
|
||||
|
||||
const SLOT_JACK = { side: "jack", label: "Домкрат" };
|
||||
|
||||
/** На приёмке (return) общие ракурсы не нужны — машина уже знакома, фокус
|
||||
* на проверке повреждений и комплектации. Домкрат остаётся всегда. */
|
||||
function slotsForType(type: string): typeof SLOTS_GENERAL {
|
||||
if (type === "return") return [SLOT_JACK];
|
||||
return [...SLOTS_GENERAL, SLOT_JACK];
|
||||
}
|
||||
|
||||
const SEVERITY_LABELS: Record<string, string> = {
|
||||
cosmetic: "косметика",
|
||||
minor: "лёгкое",
|
||||
@@ -61,11 +74,18 @@ const DAMAGE_TYPE_LABELS: Record<string, string> = {
|
||||
// Vendor 3-position severity index → backend literal
|
||||
const SEVERITY_MAP = ["minor", "moderate", "severe"] as const;
|
||||
|
||||
function humanizeMarkerSide(side: string | null | undefined): string {
|
||||
function humanizeMarkerSide(
|
||||
side: string | null | undefined,
|
||||
zoneLabels?: Record<string, string> | null,
|
||||
): string {
|
||||
if (!side) return "—";
|
||||
if (side === "tires") return "Резина";
|
||||
const zm = /^zone-(\d+)$/.exec(side);
|
||||
if (zm) return `Зона ${zm[1]}`;
|
||||
if (zm) {
|
||||
const label = zoneLabels?.[zm[1]];
|
||||
if (label) return label;
|
||||
return `Зона ${zm[1]}`;
|
||||
}
|
||||
const map: Record<string, string> = {
|
||||
top: "Сверху",
|
||||
front: "Перед",
|
||||
@@ -93,7 +113,8 @@ type DamageFlow =
|
||||
| { kind: "confirm"; zoneId: number; zoneLabel: string }
|
||||
| { kind: "placePoints"; zoneId: number; zoneLabel: string }
|
||||
| { kind: "classify"; zoneId: number; zoneLabel: string; points: { x: number; y: number }[] }
|
||||
| { kind: "capture"; zoneId: number; zoneLabel: string; points: { x: number; y: number }[]; damageType: string; severity: 0 | 1 | 2 };
|
||||
| { kind: "capture"; zoneId: number; zoneLabel: string; points: { x: number; y: number }[]; damageType: string; severity: 0 | 1 | 2 }
|
||||
| { kind: "annotate"; zoneId: number; zoneLabel: string; points: { x: number; y: number }[]; damageType: string; severity: 0 | 1 | 2; photoFile: File };
|
||||
|
||||
export default function InspectionEditor() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -121,6 +142,15 @@ export default function InspectionEditor() {
|
||||
onError: () => toast.error("Не удалось завершить"),
|
||||
});
|
||||
|
||||
const removeMarker = useMutation({
|
||||
mutationFn: (markerId: number) => deleteMarker(markerId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
|
||||
toast.success("Метка удалена");
|
||||
},
|
||||
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>;
|
||||
@@ -128,6 +158,9 @@ export default function InspectionEditor() {
|
||||
// Photos lookup by side+slot for initialPhotoId
|
||||
const photosBySide = new Map<string, typeof ins.photos[0]>();
|
||||
for (const p of ins.photos) photosBySide.set(`${p.side}-${p.slot_index}`, p);
|
||||
// Photos lookup by id — нужен для marker thumb (annotated vs original)
|
||||
const photosById = new Map<number, typeof ins.photos[0]>();
|
||||
for (const p of ins.photos) photosById.set(p.id, p);
|
||||
|
||||
const editable = ins.status === "in_progress";
|
||||
|
||||
@@ -166,6 +199,7 @@ export default function InspectionEditor() {
|
||||
damage_type: m.damage_type,
|
||||
severity: m.severity,
|
||||
resolved: m.resolved,
|
||||
polygon: m.polygon,
|
||||
}))}
|
||||
onZoneTap={editable ? handleZoneTap : undefined}
|
||||
onTireWearClick={editable ? () => setTireWearOpen(true) : undefined}
|
||||
@@ -221,21 +255,64 @@ export default function InspectionEditor() {
|
||||
|
||||
{flow.kind === "capture" && (
|
||||
<CapturePhotoStep
|
||||
inspectionId={insId}
|
||||
zoneLabel={flow.zoneLabel}
|
||||
onCancel={() => setFlow({ kind: "idle" })}
|
||||
onPhotoUploaded={async (photoId) => {
|
||||
onPhotoCaptured={(file) =>
|
||||
setFlow({
|
||||
kind: "annotate",
|
||||
zoneId: flow.zoneId,
|
||||
zoneLabel: flow.zoneLabel,
|
||||
points: flow.points,
|
||||
damageType: flow.damageType,
|
||||
severity: flow.severity,
|
||||
photoFile: file,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flow.kind === "annotate" && (
|
||||
<PhotoAnnotateStep
|
||||
photoFile={flow.photoFile}
|
||||
zoneLabel={flow.zoneLabel}
|
||||
onCancel={() =>
|
||||
setFlow({
|
||||
kind: "capture",
|
||||
zoneId: flow.zoneId,
|
||||
zoneLabel: flow.zoneLabel,
|
||||
points: flow.points,
|
||||
damageType: flow.damageType,
|
||||
severity: flow.severity,
|
||||
})
|
||||
}
|
||||
onSubmit={async (annotated) => {
|
||||
try {
|
||||
const damageEnum = damageTypeRuToEnum(flow.damageType);
|
||||
if (!damageEnum) {
|
||||
toast.error(`Не поддерживается: ${flow.damageType}`);
|
||||
setFlow({ kind: "idle" });
|
||||
return;
|
||||
}
|
||||
const result = await uploadPhoto(insId, "free", 0, flow.photoFile);
|
||||
if (annotated) {
|
||||
try {
|
||||
await uploadPhotoAnnotation(insId, result.id, annotated);
|
||||
} catch (err) {
|
||||
// Аннотация — best-effort. Фото оригинал уже сохранилось.
|
||||
console.warn("annotation upload failed (non-fatal)", err);
|
||||
}
|
||||
}
|
||||
await createMarker(insId, {
|
||||
side: `zone-${flow.zoneId}`,
|
||||
polygon: flow.points,
|
||||
damage_type: flow.damageType,
|
||||
damage_type: damageEnum,
|
||||
severity: SEVERITY_MAP[flow.severity],
|
||||
photo_id: photoId,
|
||||
photo_id: result.id,
|
||||
});
|
||||
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
|
||||
toast.success("Повреждение записано");
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error("save failed", err);
|
||||
toast.error("Не удалось сохранить");
|
||||
} finally {
|
||||
setFlow({ kind: "idle" });
|
||||
@@ -258,7 +335,7 @@ export default function InspectionEditor() {
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">Фото</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SLOTS.map((s) => (
|
||||
{slotsForType(ins.type).map((s) => (
|
||||
<PhotoSlot
|
||||
key={`${s.side}-0`}
|
||||
inspectionId={insId}
|
||||
@@ -285,8 +362,8 @@ export default function InspectionEditor() {
|
||||
<Card className="p-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{humanizeMarkerSide(m.side)}
|
||||
<span className="font-medium capitalize">
|
||||
{humanizeMarkerSide(m.side, zoneLabels)}
|
||||
</span>
|
||||
{" — "}
|
||||
<span>
|
||||
@@ -303,26 +380,64 @@ export default function InspectionEditor() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{m.carried_over_from_id && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
перенесено
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{m.carried_over_from_id && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
перенесено
|
||||
</span>
|
||||
)}
|
||||
{!m.carried_over_from_id &&
|
||||
m.description?.startsWith("Перенесено из Element Mechanic") && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
из Element Mechanic
|
||||
</span>
|
||||
)}
|
||||
{editable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const verb = m.carried_over_from_id
|
||||
? "Удалить (повреждение устранено в ремонте)?"
|
||||
: "Удалить метку?";
|
||||
if (window.confirm(verb)) {
|
||||
removeMarker.mutate(m.id);
|
||||
}
|
||||
}}
|
||||
disabled={removeMarker.isPending}
|
||||
className="text-xs text-destructive hover:underline px-1"
|
||||
title="Удалить метку"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{m.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{m.description}
|
||||
</div>
|
||||
)}
|
||||
{m.photo_id && (
|
||||
{m.photo_id ? (
|
||||
<div className="mt-2 max-w-[8rem]">
|
||||
<AuthImg
|
||||
src={`photos/${m.photo_id}/thumb`}
|
||||
src={
|
||||
photosById.get(m.photo_id)?.annotated_key
|
||||
? `photos/${m.photo_id}/annotated`
|
||||
: `photos/${m.photo_id}/thumb`
|
||||
}
|
||||
alt="Фото повреждения"
|
||||
className="w-full aspect-[4/3] object-cover rounded bg-muted"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : m.tt_image_id ? (
|
||||
<div className="mt-2 max-w-[8rem]">
|
||||
<img
|
||||
src={ttImageUrl(m.tt_image_id)}
|
||||
alt="Фото из Element Mechanic"
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] object-cover rounded bg-muted"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
@@ -344,29 +459,14 @@ export default function InspectionEditor() {
|
||||
}
|
||||
|
||||
function CapturePhotoStep({
|
||||
inspectionId,
|
||||
zoneLabel,
|
||||
onCancel,
|
||||
onPhotoUploaded,
|
||||
onPhotoCaptured,
|
||||
}: {
|
||||
inspectionId: number;
|
||||
zoneLabel: string;
|
||||
onCancel: () => void;
|
||||
onPhotoUploaded: (photoId: number) => void | Promise<void>;
|
||||
onPhotoCaptured: (file: File) => void;
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
async function handleFile(file: File) {
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await uploadPhoto(inspectionId, "free", 0, file);
|
||||
await onPhotoUploaded(result.id);
|
||||
} catch {
|
||||
toast.error("Ошибка загрузки фото");
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-background flex flex-col">
|
||||
<header className="p-4 border-b">
|
||||
@@ -375,13 +475,12 @@ function CapturePhotoStep({
|
||||
</header>
|
||||
<main className="flex-1 p-4 flex flex-col justify-center max-w-md w-full mx-auto">
|
||||
<CameraCapture
|
||||
onCapture={handleFile}
|
||||
buttonLabel={uploading ? "Загружаю…" : "Сделать фото"}
|
||||
onCapture={onPhotoCaptured}
|
||||
buttonLabel="Сделать фото"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={uploading}
|
||||
className="mt-4 w-full"
|
||||
>
|
||||
Отмена
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getInspection, type MarkerSummary, type PhotoSummary } from "@/api/inspections";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getInspection,
|
||||
deleteInspection,
|
||||
type MarkerSummary,
|
||||
type PhotoSummary,
|
||||
} from "@/api/inspections";
|
||||
import { getMe } from "@/api/me";
|
||||
import { AuthImg } from "@/components/AuthImg";
|
||||
import { ttImageUrl } from "@/api/vehicles";
|
||||
import { VendorVehicleScheme } from "@/components/vehicle-scheme/VendorVehicleScheme";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { loadZoneLabels } from "@/data/zoneLabels";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
in_progress: "В работе",
|
||||
@@ -37,11 +46,18 @@ const DAMAGE_TYPE_LABELS: Record<string, string> = {
|
||||
"not-working": "не работает",
|
||||
};
|
||||
|
||||
function humanizeMarkerSide(side: string | null | undefined): string {
|
||||
function humanizeMarkerSide(
|
||||
side: string | null | undefined,
|
||||
zoneLabels?: Record<string, string> | null,
|
||||
): string {
|
||||
if (!side) return "—";
|
||||
if (side === "tires") return "Резина";
|
||||
const zm = /^zone-(\d+)$/.exec(side);
|
||||
if (zm) return `Зона ${zm[1]}`;
|
||||
if (zm) {
|
||||
const label = zoneLabels?.[zm[1]];
|
||||
if (label) return label;
|
||||
return `Зона ${zm[1]}`;
|
||||
}
|
||||
const map: Record<string, string> = {
|
||||
top: "Сверху",
|
||||
front: "Перед",
|
||||
@@ -74,10 +90,38 @@ export default function InspectionReview() {
|
||||
queryFn: () => getInspection(insId),
|
||||
});
|
||||
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: getMe,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const isAdmin = me?.permissions?.includes("admin") ?? false;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteInspection(insId),
|
||||
onSuccess: () => {
|
||||
toast.success("Осмотр удалён");
|
||||
queryClient.invalidateQueries({ queryKey: ["vehicle"] });
|
||||
navigate(-1);
|
||||
},
|
||||
onError: () => toast.error("Не удалось удалить осмотр"),
|
||||
});
|
||||
|
||||
const { data: zoneLabels } = useQuery({
|
||||
queryKey: ["zone-labels"],
|
||||
queryFn: loadZoneLabels,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="p-8 text-muted-foreground">Загружаю…</div>;
|
||||
if (isError || !ins)
|
||||
return <div className="p-8 text-destructive">Осмотр не найден.</div>;
|
||||
|
||||
// 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);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-4 space-y-4">
|
||||
<button
|
||||
@@ -127,6 +171,7 @@ export default function InspectionReview() {
|
||||
damage_type: m.damage_type,
|
||||
severity: m.severity,
|
||||
resolved: m.resolved,
|
||||
polygon: m.polygon,
|
||||
}))}
|
||||
editable={false}
|
||||
/>
|
||||
@@ -167,8 +212,8 @@ export default function InspectionReview() {
|
||||
<Card className="p-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{humanizeMarkerSide(m.side)}
|
||||
<span className="font-medium capitalize">
|
||||
{humanizeMarkerSide(m.side, zoneLabels)}
|
||||
</span>
|
||||
{" — "}
|
||||
<span>
|
||||
@@ -203,21 +248,53 @@ export default function InspectionReview() {
|
||||
{m.description}
|
||||
</div>
|
||||
)}
|
||||
{m.photo_id && (
|
||||
{m.photo_id ? (
|
||||
<div className="mt-2 max-w-[8rem]">
|
||||
<AuthImg
|
||||
src={`photos/${m.photo_id}/thumb`}
|
||||
src={
|
||||
photosById.get(m.photo_id)?.annotated_key
|
||||
? `photos/${m.photo_id}/annotated`
|
||||
: `photos/${m.photo_id}/thumb`
|
||||
}
|
||||
alt="Фото повреждения"
|
||||
className="w-full aspect-[4/3] object-cover rounded bg-muted"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : m.tt_image_id ? (
|
||||
<div className="mt-2 max-w-[8rem]">
|
||||
<img
|
||||
src={ttImageUrl(m.tt_image_id)}
|
||||
alt="Фото из Element Mechanic"
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] object-cover rounded bg-muted"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Удалить этот осмотр? Все фото будут безвозвратно удалены из хранилища. Действие необратимо."
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="w-full py-3 rounded-lg border border-destructive text-destructive hover:bg-destructive/10 active:bg-destructive/20 transition-colors font-semibold"
|
||||
>
|
||||
{deleteMutation.isPending ? "Удаляю…" : "Удалить осмотр"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getTtState,
|
||||
deleteTtState,
|
||||
ttImageUrl,
|
||||
type TtPhotoRef,
|
||||
type TtSidePhoto,
|
||||
} from "@/api/vehicles";
|
||||
import { getMe } from "@/api/me";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { TtDamageMap } from "@/components/tt/TtDamageMap";
|
||||
|
||||
interface ZoneLabels {
|
||||
[zoneId: string]: string;
|
||||
}
|
||||
|
||||
interface DamageVocabulary {
|
||||
damage_types_ordered: string[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
let zoneLabelsCache: ZoneLabels | null = null;
|
||||
let damageVocabCache: DamageVocabulary | null = null;
|
||||
|
||||
async function loadZoneLabels(): Promise<ZoneLabels> {
|
||||
if (zoneLabelsCache) return zoneLabelsCache;
|
||||
const res = await fetch("/data/zone_labels.json");
|
||||
zoneLabelsCache = await res.json();
|
||||
return zoneLabelsCache!;
|
||||
}
|
||||
|
||||
async function loadDamageVocabulary(): Promise<DamageVocabulary> {
|
||||
if (damageVocabCache) return damageVocabCache;
|
||||
const res = await fetch("/data/damage_vocabulary.json");
|
||||
damageVocabCache = await res.json();
|
||||
return damageVocabCache!;
|
||||
}
|
||||
|
||||
const DEGREE_LABELS: Record<number, string> = {
|
||||
0: "Лёгкое",
|
||||
1: "Среднее",
|
||||
2: "Тяжёлое",
|
||||
};
|
||||
|
||||
function damageTypeName(
|
||||
id: number | null,
|
||||
vocab: DamageVocabulary | undefined,
|
||||
): string {
|
||||
if (id == null) return "Без типа";
|
||||
// Vendor enum is 1-based: type=1 → damage_types_ordered[0] ("Вмятина").
|
||||
const idx = id - 1;
|
||||
const arr = vocab?.damage_types_ordered;
|
||||
if (arr && idx >= 0 && idx < arr.length) return arr[idx];
|
||||
return `Тип ${id}`;
|
||||
}
|
||||
|
||||
const SIDE_LABELS: Record<number, string> = {
|
||||
1: "Перед",
|
||||
2: "Зад",
|
||||
3: "Левый борт",
|
||||
4: "Правый борт",
|
||||
5: "Сверху",
|
||||
6: "Передний угол",
|
||||
7: "Задний угол",
|
||||
8: "Салон",
|
||||
9: "Дополнительно",
|
||||
};
|
||||
|
||||
type PreviewItem =
|
||||
| { kind: "zone"; photo: TtPhotoRef }
|
||||
| { kind: "side"; photo: TtSidePhoto };
|
||||
|
||||
export default function TtStateDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const stateId = Number(id);
|
||||
const zoneRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const { data: state, isLoading, isError } = useQuery({
|
||||
queryKey: ["tt-state", stateId],
|
||||
queryFn: () => getTtState(stateId),
|
||||
enabled: Number.isFinite(stateId),
|
||||
});
|
||||
|
||||
const { data: labels } = useQuery({
|
||||
queryKey: ["zone-labels"],
|
||||
queryFn: loadZoneLabels,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const { data: vocab } = useQuery({
|
||||
queryKey: ["damage-vocabulary"],
|
||||
queryFn: loadDamageVocabulary,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: getMe,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const isAdmin = me?.permissions?.includes("admin") ?? false;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteTtState(stateId),
|
||||
onSuccess: () => {
|
||||
toast.success("Осмотр удалён");
|
||||
// Drop any cached tt-history for vehicle list so новый рендер не покажет удалённый.
|
||||
queryClient.invalidateQueries({ queryKey: ["vehicle"] });
|
||||
navigate(-1);
|
||||
},
|
||||
onError: () => toast.error("Не удалось удалить осмотр"),
|
||||
});
|
||||
|
||||
const [preview, setPreview] = useState<PreviewItem | null>(null);
|
||||
const [showLines, setShowLines] = useState(false);
|
||||
|
||||
const damagedZoneIds = useMemo(() => {
|
||||
if (!state) return [];
|
||||
return Object.keys(state.damages_by_zone).map((k) => Number(k));
|
||||
}, [state]);
|
||||
|
||||
const damageOverlays = useMemo(() => {
|
||||
if (!state) return [];
|
||||
return Object.entries(state.damages_by_zone).flatMap(([zid, dmgs]) =>
|
||||
dmgs.map((d) => ({ zoneId: Number(zid), points: d.points || [] }))
|
||||
);
|
||||
}, [state]);
|
||||
|
||||
// Reset refs when state changes
|
||||
useEffect(() => {
|
||||
zoneRefs.current = {};
|
||||
}, [stateId]);
|
||||
|
||||
if (isLoading)
|
||||
return <div className="p-8 text-muted-foreground">Загружаю…</div>;
|
||||
if (isError || !state)
|
||||
return <div className="p-8 text-destructive">Осмотр не найден.</div>;
|
||||
|
||||
const date = state.unix_time
|
||||
? new Date(state.unix_time * 1000).toLocaleString("ru-RU")
|
||||
: "—";
|
||||
|
||||
const zoneIds = Array.from(
|
||||
new Set([
|
||||
...Object.keys(state.photos_by_zone),
|
||||
...Object.keys(state.damages_by_zone),
|
||||
])
|
||||
).sort((a, b) => Number(a) - Number(b));
|
||||
|
||||
function scrollToZone(zoneId: number) {
|
||||
const el = zoneRefs.current[String(zoneId)];
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-4 space-y-4 pb-20">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Назад
|
||||
</button>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="text-xs text-muted-foreground">Element Mechanic</div>
|
||||
<div className="text-lg font-semibold mt-1">
|
||||
{state.mechanic_name || "Осмотр"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{date}
|
||||
{state.mileage != null && (
|
||||
<> · {state.mileage.toLocaleString("ru-RU")} км</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Общие 9 ракурсов */}
|
||||
{state.side_photos.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
Общие фото
|
||||
</h2>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{state.side_photos.map((p) => {
|
||||
const label = p.photo_type
|
||||
? SIDE_LABELS[p.photo_type] || `Ракурс ${p.photo_type}`
|
||||
: "Ракурс";
|
||||
const thumbSrc = p.miniature_id
|
||||
? ttImageUrl(p.miniature_id)
|
||||
: ttImageUrl(p.image_id);
|
||||
return (
|
||||
<button
|
||||
key={p.image_id}
|
||||
onClick={() => {
|
||||
setPreview({ kind: "side", photo: p });
|
||||
setShowLines(false);
|
||||
}}
|
||||
className="aspect-square overflow-hidden rounded-lg bg-muted hover:opacity-80 transition-opacity relative"
|
||||
>
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt={label}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-1">
|
||||
<div className="text-white text-[10px] font-medium truncate">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Карта повреждений */}
|
||||
{damagedZoneIds.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
Карта повреждений
|
||||
<span className="ml-2 text-xs font-normal">
|
||||
{damagedZoneIds.length}{" "}
|
||||
{damagedZoneIds.length === 1 ? "зона" : "зон"}
|
||||
</span>
|
||||
</h2>
|
||||
<TtDamageMap
|
||||
damagedZoneIds={damagedZoneIds}
|
||||
damages={damageOverlays}
|
||||
onZoneClick={scrollToZone}
|
||||
/>
|
||||
<div className="text-[11px] text-muted-foreground mt-1 text-center">
|
||||
Тап на зону — прокрутка к деталям
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Детали по зонам с повреждениями */}
|
||||
{zoneIds.length === 0 ? (
|
||||
<Card className="p-4 text-sm text-muted-foreground">
|
||||
В осмотре нет ни фото, ни повреждений.
|
||||
</Card>
|
||||
) : (
|
||||
zoneIds.map((zid) => {
|
||||
const photos = state.photos_by_zone[zid] || [];
|
||||
const damages = state.damages_by_zone[zid] || [];
|
||||
const label = labels?.[zid] || `Зона ${zid}`;
|
||||
return (
|
||||
<div
|
||||
key={zid}
|
||||
ref={(el) => {
|
||||
zoneRefs.current[zid] = el;
|
||||
}}
|
||||
className="scroll-mt-4"
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2 capitalize flex items-center justify-between">
|
||||
<span>{label}</span>
|
||||
{damages.length > 0 && (
|
||||
<span className="text-destructive text-xs">
|
||||
⚠ {damages.length}{" "}
|
||||
{damages.length === 1 ? "повреждение" : "повреждений"}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{damages.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{damages.map((d, idx) => {
|
||||
const tname = damageTypeName(d.damage_type_id, vocab);
|
||||
const dname =
|
||||
d.degree != null ? DEGREE_LABELS[d.degree] : null;
|
||||
return (
|
||||
<span
|
||||
key={(d.guid ?? "g") + "_" + idx}
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-destructive/10 text-destructive border border-destructive/30"
|
||||
>
|
||||
<span className="font-medium">{tname}</span>
|
||||
{dname && (
|
||||
<>
|
||||
<span className="opacity-50">·</span>
|
||||
<span>{dname}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{photos.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{photos.map((p, idx) => (
|
||||
<button
|
||||
key={p.image_id + "_" + idx}
|
||||
onClick={() => {
|
||||
setPreview({ kind: "zone", photo: p });
|
||||
setShowLines(false);
|
||||
}}
|
||||
className="aspect-square overflow-hidden rounded-lg bg-muted hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src={ttImageUrl(p.image_id)}
|
||||
alt={`Зона ${zid}`}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-3 text-xs text-muted-foreground">
|
||||
Фото нет — только координаты повреждений
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Скрыть этот осмотр из истории? Будет помечен как игнорируемый — sync не вернёт. Действие обратимо только из БД."
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="w-full py-3 rounded-lg border border-destructive text-destructive hover:bg-destructive/10 active:bg-destructive/20 transition-colors font-semibold"
|
||||
>
|
||||
{deleteMutation.isPending ? "Удаляю…" : "Удалить осмотр"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/90 flex flex-col"
|
||||
onClick={() => setPreview(null)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreview(null);
|
||||
}}
|
||||
className="text-white text-2xl px-3"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{preview.kind === "zone" &&
|
||||
preview.photo.image_with_lines_id && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowLines((v) => !v);
|
||||
}}
|
||||
className="text-white text-sm border border-white/40 px-3 py-1 rounded-lg"
|
||||
>
|
||||
{showLines ? "Без линий" : "С линиями"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={ttImageUrl(
|
||||
preview.kind === "zone" &&
|
||||
showLines &&
|
||||
preview.photo.image_with_lines_id
|
||||
? preview.photo.image_with_lines_id
|
||||
: preview.photo.image_id
|
||||
)}
|
||||
alt="Просмотр"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { getVehicle } from "@/api/vehicles";
|
||||
import { getVehicle, getTtHistory } from "@/api/vehicles";
|
||||
import { createInspection, type InspectionType } from "@/api/inspections";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -10,7 +10,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const INSPECTION_TYPE_LABELS: Record<InspectionType, string> = {
|
||||
handover: "Передача",
|
||||
handover: "Выдача",
|
||||
return: "Приёмка",
|
||||
periodic: "Плановый",
|
||||
"ad-hoc": "Свободный",
|
||||
@@ -27,6 +27,18 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
cancelled: "Отменён",
|
||||
};
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const sec = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (sec < 60) return "только что";
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min} мин назад`;
|
||||
const h = Math.floor(min / 60);
|
||||
if (h < 24) return `${h} ч назад`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 30) return `${d} дн назад`;
|
||||
return new Date(iso).toLocaleDateString("ru-RU");
|
||||
}
|
||||
|
||||
export default function VehicleCard() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -37,12 +49,24 @@ export default function VehicleCard() {
|
||||
|
||||
const [pendingType, setPendingType] = useState<InspectionType | null>(null);
|
||||
const [mileageInput, setMileageInput] = useState("");
|
||||
const [mileageSource, setMileageSource] = useState<
|
||||
"starline" | "last-inspection" | null
|
||||
>(null);
|
||||
|
||||
const { data: v, isLoading, isError } = useQuery({
|
||||
queryKey: ["vehicle", vehicleId],
|
||||
queryFn: () => getVehicle(vehicleId),
|
||||
});
|
||||
|
||||
// История из vendor (Element Mechanic). Молча возвращает пустой список
|
||||
// если машина ещё не синхронизирована — секцию просто скрываем.
|
||||
const { data: tt } = useQuery({
|
||||
queryKey: ["vehicle", vehicleId, "tt-history"],
|
||||
queryFn: () => getTtHistory(vehicleId),
|
||||
enabled: Number.isFinite(vehicleId),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const startInspection = useMutation({
|
||||
mutationFn: (input: { type: InspectionType; mileage: number | undefined }) =>
|
||||
createInspection({ vehicle_id: vehicleId, type: input.type, mileage: input.mileage }),
|
||||
@@ -54,8 +78,21 @@ export default function VehicleCard() {
|
||||
|
||||
const handleStartClick = (type: InspectionType) => {
|
||||
setPendingType(type);
|
||||
// Приоритет: 1) StarLine OBD (свежий одометр), 2) пробег прошлого
|
||||
// осмотра. Менеджер всегда может скорректировать.
|
||||
if (v?.starline_mileage != null) {
|
||||
setMileageInput(String(v.starline_mileage));
|
||||
setMileageSource("starline");
|
||||
return;
|
||||
}
|
||||
const lastWithMileage = v?.recent_inspections.find((i) => i.mileage != null);
|
||||
setMileageInput(lastWithMileage?.mileage ? String(lastWithMileage.mileage) : "");
|
||||
if (lastWithMileage?.mileage != null) {
|
||||
setMileageInput(String(lastWithMileage.mileage));
|
||||
setMileageSource("last-inspection");
|
||||
return;
|
||||
}
|
||||
setMileageInput("");
|
||||
setMileageSource(null);
|
||||
};
|
||||
|
||||
if (isLoading)
|
||||
@@ -91,7 +128,7 @@ export default function VehicleCard() {
|
||||
onClick={() => handleStartClick("handover")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Передача
|
||||
Выдача
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleStartClick("return")}
|
||||
@@ -103,51 +140,10 @@ export default function VehicleCard() {
|
||||
variant="outline"
|
||||
onClick={() => handleStartClick("periodic")}
|
||||
disabled={startInspection.isPending}
|
||||
className="col-span-2"
|
||||
>
|
||||
Плановый
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleStartClick("ad-hoc")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Свободный
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleStartClick("initial")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Первичный
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleStartClick("seizure")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Изъятие
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleStartClick("equipment-change")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Комплектация
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleStartClick("pre-repair")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
В ремонт
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleStartClick("post-repair")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Из ремонта
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -202,6 +198,53 @@ export default function VehicleCard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tt && tt.states.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
История осмотров (Element Mechanic)
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{tt.states.map((s) => {
|
||||
const date = s.unix_time
|
||||
? new Date(s.unix_time * 1000).toLocaleString("ru-RU")
|
||||
: "—";
|
||||
return (
|
||||
<Card
|
||||
key={s.id}
|
||||
className="p-3 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => navigate(`/tt-states/${s.id}`)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">
|
||||
{s.mechanic_name || "Осмотр"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-2 flex-wrap">
|
||||
<span>{date}</span>
|
||||
{s.mileage != null && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<span>{s.mileage.toLocaleString("ru-RU")} км</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0 text-right">
|
||||
<div>📷 {s.photos_count}</div>
|
||||
{s.damages_count > 0 && (
|
||||
<div className="text-destructive mt-0.5">
|
||||
⚠ {s.damages_count}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingType && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||
@@ -228,10 +271,32 @@ export default function VehicleCard() {
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={mileageInput}
|
||||
onChange={(e) => setMileageInput(e.target.value.replace(/\D/g, ""))}
|
||||
onChange={(e) => {
|
||||
setMileageInput(e.target.value.replace(/\D/g, ""));
|
||||
setMileageSource(null);
|
||||
}}
|
||||
placeholder="123456"
|
||||
autoFocus
|
||||
/>
|
||||
{mileageSource === "starline" && (
|
||||
<div className="text-[11px] text-muted-foreground flex items-center gap-1.5">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
<span>
|
||||
Из StarLine
|
||||
{v?.starline_mileage_at && (
|
||||
<> · {timeAgo(v.starline_mileage_at)}</>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60">
|
||||
· скорректируйте если надо
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{mileageSource === "last-inspection" && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Из прошлого осмотра · скорректируйте если надо
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
|
||||