feat(mechanic-pwa): multi-step damage flow (confirm -> zone detail -> classify -> photo)
This commit is contained in:
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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": "передняя правая стойка"
|
||||
}
|
||||
@@ -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<Awaited<ReturnType<typeof loadDamageVocabulary>> | null>(null);
|
||||
const [damageType, setDamageType] = useState<string | null>(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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md bg-card rounded-t-2xl sm:rounded-2xl shadow-xl flex flex-col max-h-[90vh]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="p-5 border-b">
|
||||
<h2 className="text-lg font-semibold capitalize">{zoneLabel}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Точек: {pointsCount}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="p-5 border-b space-y-3">
|
||||
<Label>Степень повреждения</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{([0, 1, 2] as const).map((idx) => {
|
||||
const label = vocab?.severity_scale.labels[idx] ?? ["Лёгкое", "Среднее", "Тяжёлое"][idx];
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => setSeverity(idx)}
|
||||
className={cn(
|
||||
"flex-1 py-2 px-2 rounded-md text-sm font-medium border transition-colors",
|
||||
severity === idx
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-background text-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-1.5">
|
||||
<Label>Тип повреждения</Label>
|
||||
{!vocab && <div className="text-sm text-muted-foreground">Загружаю…</div>}
|
||||
{vocab && list.map((dt) => (
|
||||
<label
|
||||
key={dt}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer border transition-colors",
|
||||
damageType === dt
|
||||
? "bg-accent border-primary"
|
||||
: "border-transparent hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="damage_type"
|
||||
value={dt}
|
||||
checked={damageType === dt}
|
||||
onChange={() => setDamageType(dt)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="text-sm">{dt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="p-5 border-t flex gap-2">
|
||||
<Button variant="outline" onClick={onCancel} className="flex-1">
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!damageType}
|
||||
onClick={() => damageType && onSave({ damageType, severity })}
|
||||
className="flex-1"
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm bg-card rounded-t-2xl sm:rounded-2xl shadow-xl p-6 space-y-5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Добавить повреждение</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1 capitalize">
|
||||
{zoneLabel}?
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onCancel} className="flex-1">
|
||||
Нет
|
||||
</Button>
|
||||
<Button onClick={onConfirm} className="flex-1">
|
||||
Да
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const [svgText, setSvgText] = useState<string | null>(null);
|
||||
const [points, setPoints] = useState<Point[]>([]);
|
||||
const [viewBox, setViewBox] = useState<string>("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<HTMLDivElement>) {
|
||||
// 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 (
|
||||
<div className="fixed inset-0 z-50 bg-background flex flex-col">
|
||||
<header className="flex items-center justify-between p-4 border-b">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Зона</div>
|
||||
<h2 className="text-lg font-semibold capitalize">{zoneLabel}</h2>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Точек: <span className="font-semibold text-foreground">{points.length}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-hidden relative bg-card">
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={handleTap}
|
||||
className="w-full h-full relative"
|
||||
style={{ touchAction: "manipulation" }}
|
||||
dangerouslySetInnerHTML={svgText ? { __html: svgText } : undefined}
|
||||
/>
|
||||
{/* Render points as absolute-positioned dots over the SVG using the COMPOSITE coords + viewBox transform */}
|
||||
<PointOverlay points={points} viewBox={viewBox} onRemoveLast={() => setPoints((p) => p.slice(0, -1))} />
|
||||
</main>
|
||||
|
||||
<footer className="border-t p-3 flex items-center gap-3 bg-card">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border border-destructive text-destructive hover:bg-destructive/10 active:bg-destructive/20 transition-colors font-semibold"
|
||||
aria-label="Отмена"
|
||||
>
|
||||
<span className="text-2xl leading-none">✕</span>
|
||||
<span>Отмена</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onConfirm(points)}
|
||||
disabled={points.length === 0}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-3 rounded-lg font-semibold transition-colors",
|
||||
points.length === 0
|
||||
? "bg-muted text-muted-foreground"
|
||||
: "bg-primary text-primary-foreground hover:opacity-90 active:opacity-80"
|
||||
)}
|
||||
aria-label="Готово"
|
||||
>
|
||||
<span className="text-2xl leading-none">✓</span>
|
||||
<span>Готово ({points.length})</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
data-point-dot="1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (i === points.length - 1) onRemoveLast();
|
||||
}}
|
||||
className="absolute w-8 h-8 -translate-x-1/2 -translate-y-1/2 rounded-full bg-destructive border-2 border-white shadow text-white text-sm font-bold flex items-center justify-center pointer-events-auto"
|
||||
style={{ left: `${xPct}%`, top: `${yPct}%` }}
|
||||
title="Тапни по последней точке, чтобы удалить"
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<number | undefined>(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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md bg-card rounded-t-2xl sm:rounded-2xl shadow-xl p-5 space-y-4 max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Новая метка</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{humanizeSide(side)}
|
||||
{x != null && y != null && ` · ${(x * 100).toFixed(1)}%, ${(y * 100).toFixed(1)}%`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="text-muted-foreground hover:text-foreground text-xl leading-none"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="damage_type">Тип повреждения</Label>
|
||||
<select
|
||||
id="damage_type"
|
||||
value={damageType}
|
||||
onChange={(e) => setDamageType(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{DAMAGE_TYPES.map((d) => (
|
||||
<option key={d.value} value={d.value}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="severity">Степень</Label>
|
||||
<select
|
||||
id="severity"
|
||||
value={severity}
|
||||
onChange={(e) => setSeverity(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{SEVERITIES.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Комментарий (опционально)</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Например: правый угол, длина ~10см"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Фото повреждения (опционально)</Label>
|
||||
{photoId ? (
|
||||
<div className="space-y-2">
|
||||
<AuthImg
|
||||
src={`photos/${photoId}/thumb`}
|
||||
alt="Фото повреждения"
|
||||
className="w-full aspect-[4/3] object-cover rounded bg-muted"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPhotoId(undefined)}
|
||||
className="w-full"
|
||||
>
|
||||
Удалить фото
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<CameraCapture
|
||||
onCapture={handlePhoto}
|
||||
buttonLabel={uploading ? "Загружаю…" : "Сделать фото повреждения"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="flex-1"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={createMut.isPending || uploading}
|
||||
onClick={() =>
|
||||
createMut.mutate({
|
||||
side,
|
||||
x: x ?? undefined,
|
||||
y: y ?? undefined,
|
||||
damage_type: damageType,
|
||||
severity,
|
||||
description: description.trim() || undefined,
|
||||
photo_id: photoId,
|
||||
})
|
||||
}
|
||||
className="flex-1"
|
||||
>
|
||||
{createMut.isPending ? "Добавляю…" : "Добавить"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<DamageVocabulary> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
let cache: Record<string, string> | null = null;
|
||||
|
||||
export async function loadZoneLabels(): Promise<Record<string, string>> {
|
||||
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<string, string>;
|
||||
return cache;
|
||||
}
|
||||
|
||||
export function getZoneLabelSync(zoneId: number, labels: Record<string, string> | null): string {
|
||||
return labels?.[String(zoneId)] ?? `зона ${zoneId}`;
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
"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<DamageFlow>({ kind: "idle" });
|
||||
const [zoneLabels, setZoneLabels] = useState<Record<string, string> | 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 (
|
||||
<div className="min-h-screen bg-background p-4 space-y-4">
|
||||
<button
|
||||
@@ -143,24 +167,79 @@ export default function InspectionEditor() {
|
||||
severity: m.severity,
|
||||
resolved: m.resolved,
|
||||
}))}
|
||||
onZoneTap={
|
||||
editable ? (zid) => setPendingTap({ side: `zone-${zid}` }) : undefined
|
||||
}
|
||||
onZoneTap={editable ? handleZoneTap : undefined}
|
||||
onTireWearClick={editable ? () => setTireWearOpen(true) : undefined}
|
||||
editable={editable}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{pendingTap && (
|
||||
<AddMarkerDialog
|
||||
{/* Multi-step damage flow */}
|
||||
{flow.kind === "confirm" && (
|
||||
<ZoneConfirmDialog
|
||||
open
|
||||
zoneLabel={flow.zoneLabel}
|
||||
onConfirm={() =>
|
||||
setFlow({ kind: "placePoints", zoneId: flow.zoneId, zoneLabel: flow.zoneLabel })
|
||||
}
|
||||
onCancel={() => setFlow({ kind: "idle" })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flow.kind === "placePoints" && (
|
||||
<ZoneDetailView
|
||||
open
|
||||
zoneId={flow.zoneId}
|
||||
zoneLabel={flow.zoneLabel}
|
||||
onConfirm={(points) =>
|
||||
setFlow({ kind: "classify", zoneId: flow.zoneId, zoneLabel: flow.zoneLabel, points })
|
||||
}
|
||||
onCancel={() => setFlow({ kind: "idle" })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flow.kind === "classify" && (
|
||||
<DamageClassifyDialog
|
||||
open
|
||||
zoneId={flow.zoneId}
|
||||
zoneLabel={flow.zoneLabel}
|
||||
pointsCount={flow.points.length}
|
||||
onCancel={() =>
|
||||
setFlow({ kind: "placePoints", zoneId: flow.zoneId, zoneLabel: flow.zoneLabel })
|
||||
}
|
||||
onSave={({ damageType, severity }) =>
|
||||
setFlow({
|
||||
kind: "capture",
|
||||
zoneId: flow.zoneId,
|
||||
zoneLabel: flow.zoneLabel,
|
||||
points: flow.points,
|
||||
damageType,
|
||||
severity,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flow.kind === "capture" && (
|
||||
<CapturePhotoStep
|
||||
inspectionId={insId}
|
||||
side={pendingTap.side}
|
||||
onClose={() => setPendingTap(null)}
|
||||
onCreated={() => {
|
||||
setPendingTap(null);
|
||||
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
|
||||
toast.success("Метка добавлена");
|
||||
zoneLabel={flow.zoneLabel}
|
||||
onCancel={() => setFlow({ kind: "idle" })}
|
||||
onPhotoUploaded={async (photoId) => {
|
||||
try {
|
||||
await createMarker(insId, {
|
||||
side: `zone-${flow.zoneId}`,
|
||||
polygon: flow.points,
|
||||
damage_type: flow.damageType,
|
||||
severity: SEVERITY_MAP[flow.severity],
|
||||
photo_id: photoId,
|
||||
});
|
||||
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
|
||||
toast.success("Повреждение записано");
|
||||
} catch {
|
||||
toast.error("Не удалось сохранить");
|
||||
} finally {
|
||||
setFlow({ kind: "idle" });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -263,3 +342,51 @@ export default function InspectionEditor() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapturePhotoStep({
|
||||
inspectionId,
|
||||
zoneLabel,
|
||||
onCancel,
|
||||
onPhotoUploaded,
|
||||
}: {
|
||||
inspectionId: number;
|
||||
zoneLabel: string;
|
||||
onCancel: () => void;
|
||||
onPhotoUploaded: (photoId: number) => void | Promise<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">
|
||||
<div className="text-xs text-muted-foreground">Фото повреждения</div>
|
||||
<h2 className="text-lg font-semibold capitalize">{zoneLabel}</h2>
|
||||
</header>
|
||||
<main className="flex-1 p-4 flex flex-col justify-center max-w-md w-full mx-auto">
|
||||
<CameraCapture
|
||||
onCapture={handleFile}
|
||||
buttonLabel={uploading ? "Загружаю…" : "Сделать фото"}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={uploading}
|
||||
className="mt-4 w-full"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user