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:
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: "Тяжёлое" },
|
||||
];
|
||||
|
||||
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 {
|
||||
open: boolean;
|
||||
inspectionId: number;
|
||||
view: string;
|
||||
x: number;
|
||||
y: number;
|
||||
side: string; // already-formatted side string ("zone-23", "tires", etc.)
|
||||
x?: number | null; // optional — zone-only markers leave this null
|
||||
y?: number | null;
|
||||
onClose: () => void;
|
||||
onCreated: (marker: MarkerSummary) => void;
|
||||
}
|
||||
@@ -52,7 +60,7 @@ interface Props {
|
||||
export function AddMarkerDialog({
|
||||
open,
|
||||
inspectionId,
|
||||
view,
|
||||
side,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
@@ -114,7 +122,8 @@ export function AddMarkerDialog({
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Новая метка</h2>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
@@ -210,9 +219,9 @@ export function AddMarkerDialog({
|
||||
disabled={createMut.isPending || uploading}
|
||||
onClick={() =>
|
||||
createMut.mutate({
|
||||
side: view,
|
||||
x,
|
||||
y,
|
||||
side,
|
||||
x: x ?? undefined,
|
||||
y: y ?? undefined,
|
||||
damage_type: damageType,
|
||||
severity,
|
||||
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">⊙</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,
|
||||
} from "@/api/inspections";
|
||||
import { PhotoSlot } from "@/components/camera/PhotoSlot";
|
||||
import {
|
||||
CompositeVehicleScheme,
|
||||
type ViewName,
|
||||
type BumperCornerName,
|
||||
} from "@/components/vehicle-scheme/CompositeVehicleScheme";
|
||||
import { VendorVehicleScheme } from "@/components/vehicle-scheme/VendorVehicleScheme";
|
||||
import { AddMarkerDialog } from "@/components/inspection/AddMarkerDialog";
|
||||
import { TireWearDialog } from "@/components/inspection/TireWearDialog";
|
||||
import { AuthImg } from "@/components/AuthImg";
|
||||
@@ -58,6 +54,9 @@ const DAMAGE_TYPE_LABELS: Record<string, string> = {
|
||||
|
||||
function humanizeMarkerSide(side: string | null | undefined): string {
|
||||
if (!side) return "—";
|
||||
if (side === "tires") return "Резина";
|
||||
const zm = /^zone-(\d+)$/.exec(side);
|
||||
if (zm) return `Зона ${zm[1]}`;
|
||||
const map: Record<string, string> = {
|
||||
top: "Сверху",
|
||||
front: "Перед",
|
||||
@@ -68,8 +67,7 @@ function humanizeMarkerSide(side: string | null | undefined): string {
|
||||
"bumper-fr": "Бампер ПП",
|
||||
"bumper-rl": "Бампер ЗЛ",
|
||||
"bumper-rr": "Бампер ЗП",
|
||||
tires: "Резина",
|
||||
// Legacy zone-* support (data from earlier prod)
|
||||
// Legacy named zone-* support (data from earlier prod)
|
||||
"zone-front": "Передний бампер",
|
||||
"zone-rear": "Задний бампер",
|
||||
"zone-left": "Левый бок",
|
||||
@@ -87,11 +85,7 @@ export default function InspectionEditor() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [pendingTap, setPendingTap] = useState<{
|
||||
view: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [pendingTap, setPendingTap] = useState<{ side: string } | null>(null);
|
||||
const [tireWearOpen, setTireWearOpen] = useState(false);
|
||||
|
||||
const { data: ins, isLoading, isError } = useQuery({
|
||||
@@ -139,29 +133,18 @@ export default function InspectionEditor() {
|
||||
|
||||
<Card className="p-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||
Схема — тапните на машину, чтобы добавить метку
|
||||
Схема — тапните на зону, чтобы добавить метку
|
||||
</h2>
|
||||
<CompositeVehicleScheme
|
||||
<VendorVehicleScheme
|
||||
markers={ins.markers.map((m: MarkerSummary) => ({
|
||||
id: m.id,
|
||||
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,
|
||||
severity: m.severity,
|
||||
resolved: m.resolved,
|
||||
}))}
|
||||
onViewTap={
|
||||
editable
|
||||
? (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
|
||||
onZoneTap={
|
||||
editable ? (zid) => setPendingTap({ side: `zone-${zid}` }) : undefined
|
||||
}
|
||||
onTireWearClick={editable ? () => setTireWearOpen(true) : undefined}
|
||||
editable={editable}
|
||||
@@ -172,9 +155,7 @@ export default function InspectionEditor() {
|
||||
<AddMarkerDialog
|
||||
open
|
||||
inspectionId={insId}
|
||||
view={pendingTap.view}
|
||||
x={pendingTap.x}
|
||||
y={pendingTap.y}
|
||||
side={pendingTap.side}
|
||||
onClose={() => setPendingTap(null)}
|
||||
onCreated={() => {
|
||||
setPendingTap(null);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getInspection, type MarkerSummary, type PhotoSummary } from "@/api/inspections";
|
||||
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";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
@@ -39,6 +39,9 @@ const DAMAGE_TYPE_LABELS: Record<string, string> = {
|
||||
|
||||
function humanizeMarkerSide(side: string | null | undefined): string {
|
||||
if (!side) return "—";
|
||||
if (side === "tires") return "Резина";
|
||||
const zm = /^zone-(\d+)$/.exec(side);
|
||||
if (zm) return `Зона ${zm[1]}`;
|
||||
const map: Record<string, string> = {
|
||||
top: "Сверху",
|
||||
front: "Перед",
|
||||
@@ -49,8 +52,7 @@ function humanizeMarkerSide(side: string | null | undefined): string {
|
||||
"bumper-fr": "Бампер ПП",
|
||||
"bumper-rl": "Бампер ЗЛ",
|
||||
"bumper-rr": "Бампер ЗП",
|
||||
tires: "Резина",
|
||||
// Legacy zone-* support (data from earlier prod)
|
||||
// Legacy named zone-* support (data from earlier prod)
|
||||
"zone-front": "Передний бампер",
|
||||
"zone-rear": "Задний бампер",
|
||||
"zone-left": "Левый бок",
|
||||
@@ -118,14 +120,12 @@ export default function InspectionReview() {
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||
Схема повреждений
|
||||
</h2>
|
||||
<CompositeVehicleScheme
|
||||
<VendorVehicleScheme
|
||||
markers={ins.markers.map((m: MarkerSummary) => ({
|
||||
id: m.id,
|
||||
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,
|
||||
severity: m.severity,
|
||||
resolved: m.resolved,
|
||||
}))}
|
||||
editable={false}
|
||||
|
||||
Reference in New Issue
Block a user