feat(mechanic-pwa): use vendor SVG scheme with zone-based markers (1:1 vendor coords for future import)

- Add public/scheme/exterior.svg (131 KB, viewBox 0 0 827 1209, 57 zones class="0"..class="56")
- Add public/scheme/salon.svg (53 KB, Phase 2 reserve)
- New VendorVehicleScheme: fetches SVG as separate asset (?url), strips vendor <script>,
  wires click/hover via event delegation, tints zones red/gray/blue by damage state
- AddMarkerDialog: side required, x/y optional (zone-level markers store NULL x/y)
- InspectionEditor/InspectionReview: swap CompositeVehicleScheme -> VendorVehicleScheme,
  pendingTap now {side} only, humanizeMarkerSide handles zone-{N} and legacy named zones
- Delete CompositeVehicleScheme (PNG grid + bumper suns)
- No backend changes; side=zone-{N} accepted by existing Optional[str] field
This commit is contained in:
2026-05-17 20:18:46 +10:00
parent bd7665fb1d
commit ab9ddf7289
7 changed files with 286 additions and 337 deletions
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 129 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 52 KiB

@@ -39,12 +39,20 @@ const SEVERITIES: { value: string; label: string }[] = [
{ value: "severe", label: "Тяжёлое" }, { value: "severe", label: "Тяжёлое" },
]; ];
function humanizeSide(side: string): string {
if (side === "tires") return "Резина";
const m = /^zone-(\d+)$/.exec(side);
if (m) return `Зона ${m[1]}`;
// Legacy named sides
return side;
}
interface Props { interface Props {
open: boolean; open: boolean;
inspectionId: number; inspectionId: number;
view: string; side: string; // already-formatted side string ("zone-23", "tires", etc.)
x: number; x?: number | null; // optional — zone-only markers leave this null
y: number; y?: number | null;
onClose: () => void; onClose: () => void;
onCreated: (marker: MarkerSummary) => void; onCreated: (marker: MarkerSummary) => void;
} }
@@ -52,7 +60,7 @@ interface Props {
export function AddMarkerDialog({ export function AddMarkerDialog({
open, open,
inspectionId, inspectionId,
view, side,
x, x,
y, y,
onClose, onClose,
@@ -114,7 +122,8 @@ export function AddMarkerDialog({
<div> <div>
<h2 className="text-lg font-semibold">Новая метка</h2> <h2 className="text-lg font-semibold">Новая метка</h2>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{view} · {(x * 100).toFixed(1)}%, {(y * 100).toFixed(1)}% {humanizeSide(side)}
{x != null && y != null && ` · ${(x * 100).toFixed(1)}%, ${(y * 100).toFixed(1)}%`}
</p> </p>
</div> </div>
<button <button
@@ -210,9 +219,9 @@ export function AddMarkerDialog({
disabled={createMut.isPending || uploading} disabled={createMut.isPending || uploading}
onClick={() => onClick={() =>
createMut.mutate({ createMut.mutate({
side: view, side,
x, x: x ?? undefined,
y, y: y ?? undefined,
damage_type: damageType, damage_type: damageType,
severity, severity,
description: description.trim() || undefined, description: description.trim() || undefined,
@@ -1,291 +0,0 @@
import { useRef } from "react";
import { cn } from "@/lib/utils";
export type ViewName = "top" | "front" | "back" | "left" | "right";
export type BumperCornerName = "bumper-fl" | "bumper-fr" | "bumper-rl" | "bumper-rr";
export type SchemeSide = ViewName | BumperCornerName | "tires";
export interface SchemeMarker {
id: number;
side?: string | null;
x?: number | null;
y?: number | null;
severity?: string | null;
damage_type?: string | null;
resolved: boolean;
}
interface Props {
markers: SchemeMarker[];
onViewTap?: (view: ViewName, x: number, y: number) => void;
onBumperTap?: (corner: BumperCornerName) => void;
onTireWearClick?: () => void;
onMarkerClick?: (markerId: number) => void;
editable?: boolean;
}
const VIEW_AREAS: { key: ViewName; gridArea: string; src: string; label: string }[] = [
{ key: "front", gridArea: "front", src: "/scheme/front.png", label: "Перед" },
{ key: "left", gridArea: "left", src: "/scheme/left.png", label: "Лево" },
{ key: "top", gridArea: "top", src: "/scheme/top.png", label: "Сверху" },
{ key: "right", gridArea: "right", src: "/scheme/right.png", label: "Право" },
{ key: "back", gridArea: "back", src: "/scheme/back.png", label: "Зад" },
];
const BUMPER_AREAS: {
key: BumperCornerName;
gridArea: string;
label: string;
}[] = [
{ key: "bumper-fl", gridArea: "blf", label: "ПЛ" },
{ key: "bumper-fr", gridArea: "brf", label: "ПП" },
{ key: "bumper-rl", gridArea: "blr", label: "ЗЛ" },
{ key: "bumper-rr", gridArea: "brr", label: "ЗП" },
];
// Wheel areas (decorative, 4 corners)
const WHEEL_AREAS = ["wfl", "wfr", "wrl", "wrr"] as const;
export function CompositeVehicleScheme({
markers,
onViewTap,
onBumperTap,
onTireWearClick,
onMarkerClick,
editable = true,
}: Props) {
const viewRefs = useRef<Record<ViewName, HTMLDivElement | null>>({
top: null,
front: null,
back: null,
left: null,
right: null,
});
function handleViewTap(view: ViewName, e: React.MouseEvent<HTMLDivElement>) {
if (!editable) return;
if ((e.target as HTMLElement).dataset.markerId) return;
const el = viewRefs.current[view];
if (!el) return;
const rect = el.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
onViewTap?.(view, x, y);
}
const bumperCounts: Record<BumperCornerName, number> = {
"bumper-fl": 0,
"bumper-fr": 0,
"bumper-rl": 0,
"bumper-rr": 0,
};
for (const m of markers) {
if (m.side && m.side in bumperCounts) {
bumperCounts[m.side as BumperCornerName] += 1;
}
}
const tireCount = markers.filter((m) => m.side === "tires").length;
return (
<div className="space-y-3">
{/*
Grid layout — 5 cols × 5 rows (named areas):
Row 1: wfl blf front brf wfr
Row 2: . . . . . (gap row)
Row 3: left . top . right
Row 4: . . . . . (gap row)
Row 5: wrl blr back brr wrr
Columns: [wheel] [bumper] [main view — 1fr] [bumper] [wheel]
Left/right side views sit in col 1 / col 5 of row 3 (replacing wheels there).
Actual wheel decorations sit in cols 1&5 of rows 1&5.
*/}
<div
className="mx-auto w-full max-w-md bg-card border rounded-xl p-3 select-none"
style={{
display: "grid",
gridTemplateAreas: `
"wfl blf front brf wfr"
". . . . ."
"left . top . right"
". . . . ."
"wrl blr back brr wrr"
`,
gridTemplateRows: "auto 0.35rem auto 0.35rem auto",
gridTemplateColumns: "2.5rem 2.75rem 1fr 2.75rem 2.5rem",
gap: "0.4rem",
touchAction: "manipulation",
}}
>
{/* Decorative wheels — 4 corners, not clickable */}
{WHEEL_AREAS.map((w) => (
<div
key={w}
style={{ gridArea: w }}
className="w-9 h-9 rounded-full bg-foreground/80 border-[3px] border-foreground/25 self-center justify-self-center"
aria-hidden="true"
/>
))}
{/* Bumper corner sun-zones — clickable */}
{BUMPER_AREAS.map((b) => (
<button
key={b.key}
type="button"
disabled={!editable}
onClick={() => onBumperTap?.(b.key)}
style={{ gridArea: b.gridArea }}
className={cn(
"relative w-11 h-11 self-center justify-self-center flex items-center justify-center",
"transition-transform duration-100",
editable && "hover:scale-110 active:scale-95 cursor-pointer",
!editable && "cursor-default"
)}
aria-label={`Бампер ${b.label}`}
>
{/* 10-point star SVG (sun shape) */}
<svg
viewBox="0 0 100 100"
className="absolute inset-0 w-full h-full"
aria-hidden="true"
>
<polygon
points="50,4 61,35 93,39 70,60 79,93 50,76 21,93 30,60 7,39 39,35"
fill="rgb(250 204 21)"
stroke="rgb(180 120 0)"
strokeWidth="2.5"
/>
</svg>
<span className="relative z-10 text-[10px] font-bold text-foreground leading-none">
{b.label}
</span>
{bumperCounts[b.key] > 0 && (
<span className="absolute -top-1 -right-1 z-20 bg-destructive text-destructive-foreground text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center border border-white shadow">
{bumperCounts[b.key]}
</span>
)}
</button>
))}
{/* 5 PNG view tap targets */}
{VIEW_AREAS.map((v) => {
// Aspect ratio per view — chosen to approximate actual PNG dimensions
// top: 128×128 → square; front: 69×50 → ~4:3 landscape; back: 293×201 → ~3:2;
// left: 108×44 → ~5:2 strip; right: 109×44 → ~5:2 strip
const aspectStyle: React.CSSProperties =
v.key === "top" ? { aspectRatio: "1 / 1.25" } : // slightly taller — gives more tap surface
v.key === "front" ? { aspectRatio: "4 / 3" } :
v.key === "back" ? { aspectRatio: "4 / 3" } :
/* left / right */ { aspectRatio: "5 / 2" };
return (
<div
key={v.key}
ref={(el) => {
viewRefs.current[v.key] = el;
}}
style={{ gridArea: v.gridArea, ...aspectStyle }}
onClick={(e) => handleViewTap(v.key, e)}
className={cn(
"relative bg-muted/40 rounded-lg overflow-hidden w-full",
editable && "cursor-crosshair"
)}
role={editable ? "button" : undefined}
aria-label={editable ? `Добавить метку — ${v.label}` : v.label}
>
<img
src={v.src}
alt={v.label}
draggable={false}
className="absolute inset-0 w-full h-full object-contain p-1.5"
style={{ imageRendering: "auto" }}
/>
{/* Marker dots for this view */}
{markers
.filter(
(m) => m.side === v.key && m.x != null && m.y != null
)
.map((m) => (
<button
key={m.id}
type="button"
data-marker-id={m.id}
onClick={(e) => {
e.stopPropagation();
onMarkerClick?.(m.id);
}}
className={cn(
"absolute w-5 h-5 -translate-x-1/2 -translate-y-1/2 rounded-full",
"border-2 border-white shadow-md",
"flex items-center justify-center text-[9px] font-bold text-white",
"transition-transform hover:scale-125",
m.resolved
? "bg-muted-foreground/70"
: "bg-destructive"
)}
style={{
left: `${(m.x ?? 0) * 100}%`,
top: `${(m.y ?? 0) * 100}%`,
}}
title={
[m.damage_type, m.severity].filter(Boolean).join(" / ") ||
undefined
}
>
</button>
))}
</div>
);
})}
</div>
{/* Global tire-wear control */}
<button
type="button"
onClick={onTireWearClick}
disabled={!onTireWearClick}
className={cn(
"w-full max-w-md mx-auto flex items-center justify-between gap-3",
"rounded-xl border bg-card px-4 py-3",
"text-sm font-medium",
onTireWearClick
? "hover:bg-accent active:bg-accent/80 transition-colors cursor-pointer"
: "cursor-default opacity-80"
)}
>
<span className="flex items-center gap-2">
{/* Simple wheel / tyre icon using SVG so we don't need an emoji */}
<svg
viewBox="0 0 24 24"
className="w-5 h-5 shrink-0 text-foreground/70"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="3.5" />
<line x1="12" y1="3" x2="12" y2="8.5" />
<line x1="12" y1="15.5" x2="12" y2="21" />
<line x1="3" y1="12" x2="8.5" y2="12" />
<line x1="15.5" y1="12" x2="21" y2="12" />
</svg>
<span>Состояние резины</span>
</span>
{tireCount > 0 && (
<span className="bg-destructive text-destructive-foreground text-xs font-bold rounded-full px-2 py-0.5 shrink-0">
{tireCount}
</span>
)}
</button>
{editable && (
<p className="text-xs text-center text-muted-foreground">
Тапните по части машины, чтобы добавить метку повреждения.
</p>
)}
</div>
);
}
@@ -0,0 +1,191 @@
import { useEffect, useMemo, useRef, useState } from "react";
import exteriorSvgUrl from "/scheme/exterior.svg?url";
import { cn } from "@/lib/utils";
// Zone color states
const COLOR_DEFAULT_FILL = "rgba(0, 0, 0, 0)"; // transparent
const COLOR_HOVER_FILL = "rgba(59, 130, 246, 0.20)"; // soft blue
const COLOR_HAS_DAMAGE_FILL = "rgba(239, 68, 68, 0.30)"; // red tint for zones with active damage
const COLOR_RESOLVED_FILL = "rgba(156, 163, 175, 0.30)"; // gray for resolved-only zones
export interface SchemeMarker {
id: number;
side?: string | null;
damage_type?: string | null;
severity?: string | null;
resolved: boolean;
}
interface Props {
markers: SchemeMarker[];
onZoneTap?: (zoneId: number) => void;
onTireWearClick?: () => void;
editable?: boolean;
}
/** Parse "zone-23" -> 23, anything else -> null */
function parseZoneId(side: string | null | undefined): number | null {
if (!side) return null;
const m = /^zone-(\d+)$/.exec(side);
if (!m) return null;
const n = Number(m[1]);
return Number.isFinite(n) ? n : null;
}
export function VendorVehicleScheme({
markers,
onZoneTap,
onTireWearClick,
editable = true,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [svgText, setSvgText] = useState<string | null>(null);
const [hoverZone, setHoverZone] = useState<number | null>(null);
// Load SVG once from public/scheme/exterior.svg as a separate asset
useEffect(() => {
let cancelled = false;
fetch(exteriorSvgUrl)
.then((r) => r.text())
.then((t) => {
if (!cancelled) setSvgText(t);
})
.catch(() => {
if (!cancelled) setSvgText("<!-- failed to load -->");
});
return () => {
cancelled = true;
};
}, []);
// Count damage per zone, separately track has-any vs has-only-resolved
const zoneStats = useMemo(() => {
const stats = new Map<number, { count: number; activeCount: number }>();
for (const m of markers) {
const z = parseZoneId(m.side);
if (z == null) continue;
const s = stats.get(z) ?? { count: 0, activeCount: 0 };
s.count += 1;
if (!m.resolved) s.activeCount += 1;
stats.set(z, s);
}
return stats;
}, [markers]);
const tireCount = markers.filter((m) => m.side === "tires").length;
// Apply zone fills after SVG renders or when hover/damage state changes
useEffect(() => {
if (!svgText) return;
const el = containerRef.current;
if (!el) return;
const svg = el.querySelector("svg");
if (!svg) return;
// Strip any inline <script> the vendor included
svg.querySelectorAll("script").forEach((s) => s.remove());
// Make the SVG fill its container
svg.setAttribute("width", "100%");
svg.setAttribute("height", "100%");
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
// Reset all numeric-class paths to appropriate fill
const all = svg.querySelectorAll("path[class]");
all.forEach((p) => {
const cls = p.getAttribute("class") ?? "";
const zid = /^\d+$/.test(cls) ? Number(cls) : null;
if (zid == null) return;
const s = zoneStats.get(zid);
let fill = COLOR_DEFAULT_FILL;
if (s) {
fill = s.activeCount > 0 ? COLOR_HAS_DAMAGE_FILL : COLOR_RESOLVED_FILL;
}
if (hoverZone === zid && editable) fill = COLOR_HOVER_FILL;
p.setAttribute("fill", fill);
(p as SVGElement).style.cursor = editable ? "pointer" : "default";
(p as SVGElement).style.transition = "fill 0.15s";
});
}, [svgText, zoneStats, hoverZone, editable]);
// 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;
while (node && node !== e.currentTarget) {
if (node.tagName?.toLowerCase() === "path") {
const cls = node.getAttribute("class") ?? "";
if (/^\d+$/.test(cls)) return Number(cls);
}
node = node.parentElement;
}
return null;
}
function handleClick(e: React.MouseEvent<HTMLDivElement>) {
if (!editable) return;
const z = zoneFromEvent(e);
if (z != null) onZoneTap?.(z);
}
function handleMove(e: React.MouseEvent<HTMLDivElement>) {
if (!editable) return;
setHoverZone(zoneFromEvent(e));
}
return (
<div className="space-y-3">
{/* SVG container — aspect ratio matches vendor viewBox 827×1209 */}
<div
ref={containerRef}
onClick={handleClick}
onMouseMove={handleMove}
onMouseLeave={() => setHoverZone(null)}
className={cn(
"mx-auto w-full max-w-md bg-card border rounded-lg p-2 aspect-[827/1209]",
"overflow-hidden",
)}
style={{ touchAction: "manipulation" }}
dangerouslySetInnerHTML={svgText ? { __html: svgText } : undefined}
/>
{/* Summary line: count of zones with damage */}
{zoneStats.size > 0 && (
<div className="mx-auto max-w-md text-xs text-muted-foreground text-center">
Зон с повреждениями:{" "}
<span className="font-semibold text-foreground">{zoneStats.size}</span>
{" · "}
Всего меток:{" "}
<span className="font-semibold text-foreground">
{markers.filter((m) => parseZoneId(m.side) != null).length}
</span>
</div>
)}
{/* Global tire-wear button */}
<button
type="button"
onClick={onTireWearClick}
disabled={!editable && !onTireWearClick}
className="w-full max-w-md mx-auto flex items-center justify-between gap-3 rounded-lg border bg-card px-4 py-3 hover:bg-accent transition-colors text-sm font-medium"
>
<span className="flex items-center gap-2">
<span className="text-xl">&#8857;</span>
<span>Состояние резины</span>
</span>
{tireCount > 0 && (
<span className="bg-destructive text-destructive-foreground text-xs font-bold rounded-full px-2 py-0.5">
{tireCount}
</span>
)}
</button>
{editable && (
<p className="text-xs text-center text-muted-foreground">
Тапните по зоне машины, чтобы добавить метку повреждения.
</p>
)}
</div>
);
}
@@ -8,11 +8,7 @@ import {
type MarkerSummary, type MarkerSummary,
} from "@/api/inspections"; } from "@/api/inspections";
import { PhotoSlot } from "@/components/camera/PhotoSlot"; import { PhotoSlot } from "@/components/camera/PhotoSlot";
import { import { VendorVehicleScheme } from "@/components/vehicle-scheme/VendorVehicleScheme";
CompositeVehicleScheme,
type ViewName,
type BumperCornerName,
} from "@/components/vehicle-scheme/CompositeVehicleScheme";
import { AddMarkerDialog } from "@/components/inspection/AddMarkerDialog"; import { AddMarkerDialog } from "@/components/inspection/AddMarkerDialog";
import { TireWearDialog } from "@/components/inspection/TireWearDialog"; import { TireWearDialog } from "@/components/inspection/TireWearDialog";
import { AuthImg } from "@/components/AuthImg"; import { AuthImg } from "@/components/AuthImg";
@@ -58,6 +54,9 @@ const DAMAGE_TYPE_LABELS: Record<string, string> = {
function humanizeMarkerSide(side: string | null | undefined): string { function humanizeMarkerSide(side: string | null | undefined): string {
if (!side) return "—"; if (!side) return "—";
if (side === "tires") return "Резина";
const zm = /^zone-(\d+)$/.exec(side);
if (zm) return `Зона ${zm[1]}`;
const map: Record<string, string> = { const map: Record<string, string> = {
top: "Сверху", top: "Сверху",
front: "Перед", front: "Перед",
@@ -68,8 +67,7 @@ function humanizeMarkerSide(side: string | null | undefined): string {
"bumper-fr": "Бампер ПП", "bumper-fr": "Бампер ПП",
"bumper-rl": "Бампер ЗЛ", "bumper-rl": "Бампер ЗЛ",
"bumper-rr": "Бампер ЗП", "bumper-rr": "Бампер ЗП",
tires: "Резина", // Legacy named zone-* support (data from earlier prod)
// Legacy zone-* support (data from earlier prod)
"zone-front": "Передний бампер", "zone-front": "Передний бампер",
"zone-rear": "Задний бампер", "zone-rear": "Задний бампер",
"zone-left": "Левый бок", "zone-left": "Левый бок",
@@ -87,11 +85,7 @@ export default function InspectionEditor() {
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const [pendingTap, setPendingTap] = useState<{ const [pendingTap, setPendingTap] = useState<{ side: string } | null>(null);
view: string;
x: number;
y: number;
} | null>(null);
const [tireWearOpen, setTireWearOpen] = useState(false); const [tireWearOpen, setTireWearOpen] = useState(false);
const { data: ins, isLoading, isError } = useQuery({ const { data: ins, isLoading, isError } = useQuery({
@@ -139,29 +133,18 @@ export default function InspectionEditor() {
<Card className="p-4"> <Card className="p-4">
<h2 className="text-sm font-semibold text-muted-foreground mb-3"> <h2 className="text-sm font-semibold text-muted-foreground mb-3">
Схема тапните на машину, чтобы добавить метку Схема тапните на зону, чтобы добавить метку
</h2> </h2>
<CompositeVehicleScheme <VendorVehicleScheme
markers={ins.markers.map((m: MarkerSummary) => ({ markers={ins.markers.map((m: MarkerSummary) => ({
id: m.id, id: m.id,
side: m.side, side: m.side,
x: m.x == null ? null : Number(m.x),
y: m.y == null ? null : Number(m.y),
severity: m.severity,
damage_type: m.damage_type, damage_type: m.damage_type,
severity: m.severity,
resolved: m.resolved, resolved: m.resolved,
}))} }))}
onViewTap={ onZoneTap={
editable editable ? (zid) => setPendingTap({ side: `zone-${zid}` }) : undefined
? (view: ViewName, x: number, y: number) =>
setPendingTap({ view, x, y })
: undefined
}
onBumperTap={
editable
? (corner: BumperCornerName) =>
setPendingTap({ view: corner, x: 0.5, y: 0.5 })
: undefined
} }
onTireWearClick={editable ? () => setTireWearOpen(true) : undefined} onTireWearClick={editable ? () => setTireWearOpen(true) : undefined}
editable={editable} editable={editable}
@@ -172,9 +155,7 @@ export default function InspectionEditor() {
<AddMarkerDialog <AddMarkerDialog
open open
inspectionId={insId} inspectionId={insId}
view={pendingTap.view} side={pendingTap.side}
x={pendingTap.x}
y={pendingTap.y}
onClose={() => setPendingTap(null)} onClose={() => setPendingTap(null)}
onCreated={() => { onCreated={() => {
setPendingTap(null); setPendingTap(null);
@@ -2,7 +2,7 @@ import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getInspection, type MarkerSummary, type PhotoSummary } from "@/api/inspections"; import { getInspection, type MarkerSummary, type PhotoSummary } from "@/api/inspections";
import { AuthImg } from "@/components/AuthImg"; import { AuthImg } from "@/components/AuthImg";
import { CompositeVehicleScheme } from "@/components/vehicle-scheme/CompositeVehicleScheme"; import { VendorVehicleScheme } from "@/components/vehicle-scheme/VendorVehicleScheme";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
@@ -39,6 +39,9 @@ const DAMAGE_TYPE_LABELS: Record<string, string> = {
function humanizeMarkerSide(side: string | null | undefined): string { function humanizeMarkerSide(side: string | null | undefined): string {
if (!side) return "—"; if (!side) return "—";
if (side === "tires") return "Резина";
const zm = /^zone-(\d+)$/.exec(side);
if (zm) return `Зона ${zm[1]}`;
const map: Record<string, string> = { const map: Record<string, string> = {
top: "Сверху", top: "Сверху",
front: "Перед", front: "Перед",
@@ -49,8 +52,7 @@ function humanizeMarkerSide(side: string | null | undefined): string {
"bumper-fr": "Бампер ПП", "bumper-fr": "Бампер ПП",
"bumper-rl": "Бампер ЗЛ", "bumper-rl": "Бампер ЗЛ",
"bumper-rr": "Бампер ЗП", "bumper-rr": "Бампер ЗП",
tires: "Резина", // Legacy named zone-* support (data from earlier prod)
// Legacy zone-* support (data from earlier prod)
"zone-front": "Передний бампер", "zone-front": "Передний бампер",
"zone-rear": "Задний бампер", "zone-rear": "Задний бампер",
"zone-left": "Левый бок", "zone-left": "Левый бок",
@@ -118,14 +120,12 @@ export default function InspectionReview() {
<h2 className="text-sm font-semibold text-muted-foreground mb-3"> <h2 className="text-sm font-semibold text-muted-foreground mb-3">
Схема повреждений Схема повреждений
</h2> </h2>
<CompositeVehicleScheme <VendorVehicleScheme
markers={ins.markers.map((m: MarkerSummary) => ({ markers={ins.markers.map((m: MarkerSummary) => ({
id: m.id, id: m.id,
side: m.side, side: m.side,
x: m.x == null ? null : Number(m.x),
y: m.y == null ? null : Number(m.y),
severity: m.severity,
damage_type: m.damage_type, damage_type: m.damage_type,
severity: m.severity,
resolved: m.resolved, resolved: m.resolved,
}))} }))}
editable={false} editable={false}