diff --git a/mechanic-pwa/frontend/public/data/damage_vocabulary.json b/mechanic-pwa/frontend/public/data/damage_vocabulary.json new file mode 100644 index 0000000..97b4526 --- /dev/null +++ b/mechanic-pwa/frontend/public/data/damage_vocabulary.json @@ -0,0 +1,90 @@ +{ + "_meta": { + "source": "Ttc.dll #US heap, contiguous block at offsets 0xc2e24–0xc3084", + "confidence": "HIGH — full block scanned; order is the binary storage order which matches app display order (repository pattern typically loads in definition order)", + "notes": [ + "Items 0–12 are general exterior damage types (user-selectable for any zone)", + "Items 13–18 appear to be salon-specific (Грязный салон, Запах табака, etc.) — shown only for interior zones (44–46 seats, salon)", + "Установлено/Снято (19–20) and Контроль (21) are semi-system states used for equipment completeness zones (33–48 area)", + "Штат (25) appears to be a system/staff marker, not user-selectable", + "Порез, Прокол, Порвано are tire-specific damage types for zones 17–20", + "Есть/Нет повреждений found in binary (0x4599a/0x4597a) — likely used for salon damage yes/no toggle, not in the standard list" + ] + }, + "damage_types_ordered": [ + "Вмятина", + "Царапина", + "Трещина", + "Повреждено", + "Скол", + "Отсутствует", + "Прожжено", + "Погнуто", + "Требуется мойка", + "Не работает", + "Порез", + "Прокол", + "Порвано", + "Пятна", + "Грязный салон", + "Запах табака", + "Повреждение салонных ковриков", + "Повреждение обшивки дверей", + "Сломанные ручки", + "Установлено", + "Снято", + "Контроль", + "Требуется химчистка", + "Затертость", + "Грыжа", + "Штат" + ], + "exterior_selectable": [ + "Вмятина", + "Царапина", + "Трещина", + "Повреждено", + "Скол", + "Отсутствует", + "Прожжено", + "Погнуто", + "Требуется мойка", + "Не работает", + "Порез", + "Прокол", + "Порвано", + "Пятна", + "Затертость", + "Грыжа" + ], + "salon_selectable": [ + "Вмятина", + "Царапина", + "Трещина", + "Повреждено", + "Скол", + "Отсутствует", + "Прожжено", + "Погнуто", + "Требуется мойка", + "Не работает", + "Пятна", + "Грязный салон", + "Запах табака", + "Повреждение салонных ковриков", + "Повреждение обшивки дверей", + "Сломанные ручки", + "Требуется химчистка", + "Затертость", + "Грыжа" + ], + "severity_scale": { + "positions": 3, + "labels": ["Легкое", "Среднее", "Тяжелое"], + "values": [0, 1, 2], + "widget": "SeekBar (vehicleInspectionDegreeSelect / newDamageSeekBarDamageDegree)", + "api_field": "degree (lowercase, in JSON contract)", + "default": "Легкое (index 0)", + "source_note": "Легкое found at 0x59d79 (adjacent to 'Добавить повреждение' button label = default/initial value); Среднее+Тяжелое found together at 0xc39e8–0xc39f8 (loading state transitions)" + } +} diff --git a/mechanic-pwa/frontend/public/data/zone_labels.json b/mechanic-pwa/frontend/public/data/zone_labels.json new file mode 100644 index 0000000..09726c8 --- /dev/null +++ b/mechanic-pwa/frontend/public/data/zone_labels.json @@ -0,0 +1,66 @@ +{ + "_meta": { + "source": "Ttc.dll #US heap, offsets 0x751c7–0x75a81 (consecutive UTF-16 LE strings)", + "confidence": "HIGH — 57 strings appear in exact sequential order in a single contiguous block, matching the 57 class IDs (0..56) in exterior_scheme_complete.svg", + "geographic_check": "confirmed: zone 0 (front bumper) at SVG Y=119 (top), zone 22 (rear bumper) at Y=1064 (bottom); left-side zones have small X, right-side zones have large X", + "note_typo": "zone 15 has a typo in the vendor binary: 'правй порог' (missing 'ы') — preserve as-is for matching but display as 'правый порог'", + "note_zones_34_57": "zones 34–37 are fog lights (противотуманки), zones 38–39 mirrors, 40–43 disks, 44–46 salon seats, 47–48 rear sill extensions, 49–56 bumper sub-parts and pillars" + }, + "0": "передний бампер", + "1": "передняя правая фара", + "2": "передняя левая фара", + "3": "радиатор", + "4": "капот", + "5": "лобовое стекло", + "6": "переднее левое крыло", + "7": "переднее правое крыло", + "8": "заднее левое крыло", + "9": "заднее правое крыло", + "10": "передняя левая дверь", + "11": "передняя правая дверь", + "12": "задняя левая дверь", + "13": "задняя правая дверь", + "14": "левый порог", + "15": "правый порог", + "16": "крыша", + "17": "передняя правая резина", + "18": "передняя левая резина", + "19": "задняя правая резина", + "20": "задняя левая резина", + "21": "багажник", + "22": "задний бампер", + "23": "заднее стекло", + "24": "переднее левое стекло", + "25": "переднее правое стекло", + "26": "заднее левое стекло", + "27": "заднее правое стекло", + "28": "передняя левая форточка", + "29": "передняя правая форточка", + "30": "задняя правая форточка", + "31": "задняя левая форточка", + "32": "задняя правая фара", + "33": "задняя левая фара", + "34": "передняя правая противотуманка", + "35": "передняя левая противотуманка", + "36": "задняя правая противотуманка", + "37": "задняя левая противотуманка", + "38": "правое зеркало", + "39": "левое зеркало", + "40": "задний левый диск", + "41": "задний правый диск", + "42": "передний правый диск", + "43": "передний левый диск", + "44": "водительское сидение", + "45": "пассажирское сидение", + "46": "задний диван", + "47": "задний левый порог", + "48": "задний правый порог", + "49": "задняя левая часть бампера", + "50": "задняя правая часть бампера", + "51": "передняя левая часть бампера", + "52": "передняя правая часть бампера", + "53": "задняя левая стойка", + "54": "задняя правая стойка", + "55": "передняя левая стойка", + "56": "передняя правая стойка" +} diff --git a/mechanic-pwa/frontend/src/components/damage-flow/DamageClassifyDialog.tsx b/mechanic-pwa/frontend/src/components/damage-flow/DamageClassifyDialog.tsx new file mode 100644 index 0000000..588cc33 --- /dev/null +++ b/mechanic-pwa/frontend/src/components/damage-flow/DamageClassifyDialog.tsx @@ -0,0 +1,117 @@ +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"; + +interface Props { + open: boolean; + zoneId: number; + zoneLabel: string; + pointsCount: number; + onCancel: () => void; + onSave: (data: { damageType: string; severity: 0 | 1 | 2 }) => void; +} + +export function DamageClassifyDialog({ open, zoneId, zoneLabel, pointsCount, onCancel, onSave }: Props) { + const [vocab, setVocab] = useState> | null>(null); + const [damageType, setDamageType] = useState(null); + const [severity, setSeverity] = useState<0 | 1 | 2>(0); + + useEffect(() => { + if (!open) return; + setDamageType(null); + setSeverity(0); + loadDamageVocabulary().then(setVocab); + }, [open]); + + if (!open) return null; + + // Salon zones (44=водительское сидение, 45=пассажирское, 46=задний диван) use salon_selectable; else exterior + const isSalon = zoneId === 44 || zoneId === 45 || zoneId === 46; + const list = vocab + ? (isSalon ? vocab.salon_selectable : vocab.exterior_selectable) + : []; + + return ( +
+
e.stopPropagation()} + > +
+

{zoneLabel}

+

+ Точек: {pointsCount} +

+
+ +
+ +
+ {([0, 1, 2] as const).map((idx) => { + const label = vocab?.severity_scale.labels[idx] ?? ["Лёгкое", "Среднее", "Тяжёлое"][idx]; + return ( + + ); + })} +
+
+ +
+ + {!vocab &&
Загружаю…
} + {vocab && list.map((dt) => ( + + ))} +
+ +
+ + +
+
+
+ ); +} diff --git a/mechanic-pwa/frontend/src/components/damage-flow/ZoneConfirmDialog.tsx b/mechanic-pwa/frontend/src/components/damage-flow/ZoneConfirmDialog.tsx new file mode 100644 index 0000000..621ef41 --- /dev/null +++ b/mechanic-pwa/frontend/src/components/damage-flow/ZoneConfirmDialog.tsx @@ -0,0 +1,38 @@ +import { Button } from "@/components/ui/button"; + +interface Props { + open: boolean; + zoneLabel: string; + onConfirm: () => void; + onCancel: () => void; +} + +export function ZoneConfirmDialog({ open, zoneLabel, onConfirm, onCancel }: Props) { + if (!open) return null; + return ( +
+
e.stopPropagation()} + > +
+

Добавить повреждение

+

+ {zoneLabel}? +

+
+
+ + +
+
+
+ ); +} diff --git a/mechanic-pwa/frontend/src/components/damage-flow/ZoneDetailView.tsx b/mechanic-pwa/frontend/src/components/damage-flow/ZoneDetailView.tsx new file mode 100644 index 0000000..6b38bae --- /dev/null +++ b/mechanic-pwa/frontend/src/components/damage-flow/ZoneDetailView.tsx @@ -0,0 +1,195 @@ +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; + +interface Point { + x: number; + y: number; +} + +interface Props { + open: boolean; + zoneId: number; + zoneLabel: string; + onConfirm: (points: Point[]) => void; + onCancel: () => void; +} + +const PADDING = 0.05; // 5% of bbox as padding when zooming + +export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }: Props) { + const containerRef = useRef(null); + const [svgText, setSvgText] = useState(null); + const [points, setPoints] = useState([]); + const [viewBox, setViewBox] = useState("0 0 827 1209"); + + useEffect(() => { + if (!open) return; + setPoints([]); + let cancelled = false; + fetch("/scheme/exterior.svg") + .then((r) => r.text()) + .then((t) => { if (!cancelled) setSvgText(t); }); + return () => { cancelled = true; }; + }, [open, zoneId]); + + // After SVG renders, focus on target zone + useEffect(() => { + if (!svgText) return; + const el = containerRef.current; + if (!el) return; + const svg = el.querySelector("svg") as SVGSVGElement | null; + if (!svg) return; + + // Strip vendor scripts + svg.querySelectorAll("script").forEach((s) => s.remove()); + svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + svg.setAttribute("width", "100%"); + svg.setAttribute("height", "100%"); + + // Mute every zone except target; highlight target + const allPaths = svg.querySelectorAll("path[class]"); + allPaths.forEach((p) => { + const cls = p.getAttribute("class") ?? ""; + if (cls === String(zoneId)) { + p.setAttribute("fill", "rgba(239, 68, 68, 0.15)"); // soft red highlight + p.setAttribute("stroke-width", "2"); + } else { + p.setAttribute("fill", "rgba(0,0,0,0.04)"); + p.setAttribute("stroke", "rgba(0,0,0,0.3)"); + } + (p as SVGElement).style.pointerEvents = "none"; // we handle clicks at container level + }); + + // Compute bounding box of target zone and set viewBox to zoom in + const target = svg.querySelector(`path[class="${zoneId}"]`) as SVGGraphicsElement | null; + if (target) { + const bbox = target.getBBox(); + const padX = bbox.width * PADDING; + const padY = bbox.height * PADDING; + const vb = `${bbox.x - padX} ${bbox.y - padY} ${bbox.width + 2 * padX} ${bbox.height + 2 * padY}`; + svg.setAttribute("viewBox", vb); + setViewBox(vb); + } + }, [svgText, zoneId]); + + function handleTap(e: React.MouseEvent) { + // Don't react to taps on point dots + if ((e.target as HTMLElement).dataset.pointDot) return; + + const el = containerRef.current; + if (!el) return; + const svg = el.querySelector("svg") as SVGSVGElement | null; + if (!svg) return; + + // Convert client coords -> SVG user space using the current viewBox + const pt = svg.createSVGPoint(); + pt.x = e.clientX; + pt.y = e.clientY; + const ctm = svg.getScreenCTM(); + if (!ctm) return; + const svgPt = pt.matrixTransform(ctm.inverse()); + + // Normalize to 0..1 of the COMPOSITE SVG (827x1209) — for cross-render compatibility + const x = svgPt.x / 827; + const y = svgPt.y / 1209; + + // Clamp to keep within image + const cx = Math.max(0, Math.min(1, x)); + const cy = Math.max(0, Math.min(1, y)); + + setPoints((prev) => [...prev, { x: cx, y: cy }]); + } + + if (!open) return null; + + return ( +
+
+
+
Зона
+

{zoneLabel}

+
+
+ Точек: {points.length} +
+
+ +
+
+ {/* Render points as absolute-positioned dots over the SVG using the COMPOSITE coords + viewBox transform */} + setPoints((p) => p.slice(0, -1))} /> +
+ +
+ + +
+
+ ); +} + +interface PointOverlayProps { + points: Point[]; + viewBox: string; + onRemoveLast: () => void; +} + +function PointOverlay({ points, viewBox, onRemoveLast }: PointOverlayProps) { + // viewBox is "minX minY width height" in COMPOSITE SVG coords (which are themselves 0..827 x 0..1209) + // Our points are stored normalized to composite (0..1). Convert to viewBox-relative %: + const [minX, minY, w, h] = viewBox.split(/\s+/).map(Number); + return ( + <> + {points.map((p, i) => { + const xVB = p.x * 827; + const yVB = p.y * 1209; + const xPct = ((xVB - minX) / w) * 100; + const yPct = ((yVB - minY) / h) * 100; + // Clamp visible + if (xPct < 0 || xPct > 100 || yPct < 0 || yPct > 100) return null; + return ( + + ); + })} + + ); +} diff --git a/mechanic-pwa/frontend/src/components/inspection/AddMarkerDialog.tsx b/mechanic-pwa/frontend/src/components/inspection/AddMarkerDialog.tsx deleted file mode 100644 index 95d734e..0000000 --- a/mechanic-pwa/frontend/src/components/inspection/AddMarkerDialog.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { useState } from "react"; -import { useMutation } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { CameraCapture } from "@/components/camera/CameraCapture"; -import { AuthImg } from "@/components/AuthImg"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { uploadPhoto } from "@/api/photos"; -import { - createMarker, - type MarkerCreateInput, - type MarkerSummary, -} from "@/api/inspections"; - -const DAMAGE_TYPES: { value: string; label: string }[] = [ - { value: "scratch", label: "Царапина" }, - { value: "chip", label: "Скол" }, - { value: "dent", label: "Вмятина" }, - { value: "crack", label: "Трещина" }, - { value: "scuff", label: "Затёртость" }, - { value: "burn", label: "Прожог" }, - { value: "stain", label: "Пятно" }, - { value: "bent", label: "Погнуто" }, - { value: "torn", label: "Порвано" }, - { value: "cut", label: "Порез" }, - { value: "puncture", label: "Прокол" }, - { value: "bulge", label: "Грыжа" }, - { value: "missing", label: "Отсутствует" }, - { value: "removed", label: "Снято" }, - { value: "not-working", label: "Не работает" }, - { value: "damaged", label: "Повреждено (общее)" }, -]; - -const SEVERITIES: { value: string; label: string }[] = [ - { value: "cosmetic", label: "Косметика" }, - { value: "minor", label: "Лёгкое" }, - { value: "moderate", 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 { - open: boolean; - inspectionId: 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; -} - -export function AddMarkerDialog({ - open, - inspectionId, - side, - x, - y, - onClose, - onCreated, -}: Props) { - const [damageType, setDamageType] = useState("scratch"); - const [severity, setSeverity] = useState("minor"); - const [description, setDescription] = useState(""); - const [photoId, setPhotoId] = useState(undefined); - const [uploading, setUploading] = useState(false); - - async function handlePhoto(file: File) { - setUploading(true); - try { - // Marker close-ups land in the "free" slot — backend has no slot-uniqueness constraint - const result = await uploadPhoto(inspectionId, "free", 0, file); - setPhotoId(result.id); - toast.success("Фото повреждения загружено"); - } catch { - toast.error("Ошибка загрузки фото"); - } finally { - setUploading(false); - } - } - - const createMut = useMutation({ - mutationFn: (body: MarkerCreateInput) => createMarker(inspectionId, body), - onSuccess: (marker) => { - onCreated(marker); - reset(); - }, - onError: () => toast.error("Не удалось добавить метку"), - }); - - function reset() { - setDamageType("scratch"); - setSeverity("minor"); - setDescription(""); - setPhotoId(undefined); - } - - function handleClose() { - reset(); - onClose(); - } - - if (!open) return null; - - return ( -
-
e.stopPropagation()} - > -
-
-

Новая метка

-

- {humanizeSide(side)} - {x != null && y != null && ` · ${(x * 100).toFixed(1)}%, ${(y * 100).toFixed(1)}%`} -

-
- -
- -
- - -
- -
- - -
- -
- - setDescription(e.target.value)} - placeholder="Например: правый угол, длина ~10см" - /> -
- -
- - {photoId ? ( -
- - -
- ) : ( - - )} -
- -
- - -
-
-
- ); -} diff --git a/mechanic-pwa/frontend/src/data/damageVocabulary.ts b/mechanic-pwa/frontend/src/data/damageVocabulary.ts new file mode 100644 index 0000000..8fd784b --- /dev/null +++ b/mechanic-pwa/frontend/src/data/damageVocabulary.ts @@ -0,0 +1,26 @@ +export interface DamageVocabulary { + damage_types_ordered: string[]; + exterior_selectable: string[]; + salon_selectable: string[]; + severity_scale: { + positions: number; + labels: string[]; + values: number[]; + default: string; + }; +} + +let cache: DamageVocabulary | null = null; + +export async function loadDamageVocabulary(): Promise { + if (cache) return cache; + const res = await fetch("/data/damage_vocabulary.json"); + const data = await res.json(); + cache = { + damage_types_ordered: data.damage_types_ordered, + exterior_selectable: data.exterior_selectable, + salon_selectable: data.salon_selectable, + severity_scale: data.severity_scale, + }; + return cache; +} diff --git a/mechanic-pwa/frontend/src/data/zoneLabels.ts b/mechanic-pwa/frontend/src/data/zoneLabels.ts new file mode 100644 index 0000000..56edaee --- /dev/null +++ b/mechanic-pwa/frontend/src/data/zoneLabels.ts @@ -0,0 +1,16 @@ +let cache: Record | null = null; + +export async function loadZoneLabels(): Promise> { + if (cache) return cache; + const res = await fetch("/data/zone_labels.json"); + const data = await res.json(); + // The JSON has a "_meta" key + numeric string keys → filter to numeric only + cache = Object.fromEntries( + Object.entries(data).filter(([k]) => /^\d+$/.test(k)) + ) as Record; + return cache; +} + +export function getZoneLabelSync(zoneId: number, labels: Record | null): string { + return labels?.[String(zoneId)] ?? `зона ${zoneId}`; +} diff --git a/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx b/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx index 1fe3a36..eb51a29 100644 --- a/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx +++ b/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx @@ -1,19 +1,25 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { getInspection, patchInspection, + createMarker, type MarkerSummary, } from "@/api/inspections"; +import { uploadPhoto } from "@/api/photos"; import { PhotoSlot } from "@/components/camera/PhotoSlot"; +import { CameraCapture } from "@/components/camera/CameraCapture"; import { VendorVehicleScheme } from "@/components/vehicle-scheme/VendorVehicleScheme"; -import { AddMarkerDialog } from "@/components/inspection/AddMarkerDialog"; 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 { AuthImg } from "@/components/AuthImg"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { loadZoneLabels } from "@/data/zoneLabels"; const SLOTS: { side: string; label: string }[] = [ { side: "front", label: "Перед" }, @@ -52,6 +58,9 @@ const DAMAGE_TYPE_LABELS: Record = { "not-working": "не работает", }; +// Vendor 3-position severity index → backend literal +const SEVERITY_MAP = ["minor", "moderate", "severe"] as const; + function humanizeMarkerSide(side: string | null | undefined): string { if (!side) return "—"; if (side === "tires") return "Резина"; @@ -79,15 +88,25 @@ function humanizeMarkerSide(side: string | null | undefined): string { return map[side] ?? side; } +type DamageFlow = + | { kind: "idle" } + | { 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 }; + export default function InspectionEditor() { const { id } = useParams<{ id: string }>(); const insId = Number(id); const navigate = useNavigate(); const qc = useQueryClient(); - const [pendingTap, setPendingTap] = useState<{ side: string } | null>(null); + const [flow, setFlow] = useState({ kind: "idle" }); + const [zoneLabels, setZoneLabels] = useState | null>(null); const [tireWearOpen, setTireWearOpen] = useState(false); + useEffect(() => { loadZoneLabels().then(setZoneLabels); }, []); + const { data: ins, isLoading, isError } = useQuery({ queryKey: ["inspection", insId], queryFn: () => getInspection(insId), @@ -112,6 +131,11 @@ export default function InspectionEditor() { const editable = ins.status === "in_progress"; + const handleZoneTap = (zoneId: number) => { + const label = zoneLabels?.[String(zoneId)] ?? `зона ${zoneId}`; + setFlow({ kind: "confirm", zoneId, zoneLabel: label }); + }; + return (
); } + +function CapturePhotoStep({ + inspectionId, + zoneLabel, + onCancel, + onPhotoUploaded, +}: { + inspectionId: number; + zoneLabel: string; + onCancel: () => void; + onPhotoUploaded: (photoId: number) => void | Promise; +}) { + 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 ( +
+
+
Фото повреждения
+

{zoneLabel}

+
+
+ + +
+
+ ); +}