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/.
This commit is contained in:
2026-05-20 11:16:21 +10:00
parent e0083b894f
commit 7e2cf0c9c8
132 changed files with 2823 additions and 106 deletions
+9 -1
View File
@@ -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>
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

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" }
]
}
+2
View File
@@ -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>
);
+3 -1
View File
@@ -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 };
+15
View File
@@ -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 }
+73
View File
@@ -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>
);
}
+112 -47
View File
@@ -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">