feat(mechanic-pwa): multi-step damage flow (confirm -> zone detail -> classify -> photo)

This commit is contained in:
2026-05-17 23:45:34 +10:00
parent 8893710254
commit 102a2525db
9 changed files with 689 additions and 253 deletions
@@ -0,0 +1,90 @@
{
"_meta": {
"source": "Ttc.dll #US heap, contiguous block at offsets 0xc2e240xc3084",
"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 012 are general exterior damage types (user-selectable for any zone)",
"Items 1318 appear to be salon-specific (Грязный салон, Запах табака, etc.) — shown only for interior zones (4446 seats, salon)",
"Установлено/Снято (19–20) and Контроль (21) are semi-system states used for equipment completeness zones (3348 area)",
"Штат (25) appears to be a system/staff marker, not user-selectable",
"Порез, Прокол, Порвано are tire-specific damage types for zones 1720",
"Есть/Нет повреждений 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 0xc39e80xc39f8 (loading state transitions)"
}
}
@@ -0,0 +1,66 @@
{
"_meta": {
"source": "Ttc.dll #US heap, offsets 0x751c70x75a81 (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 3437 are fog lights (противотуманки), zones 3839 mirrors, 4043 disks, 4446 salon seats, 4748 rear sill extensions, 4956 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">&#x2715;</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">&#x2713;</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 { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
getInspection, getInspection,
patchInspection, patchInspection,
createMarker,
type MarkerSummary, type MarkerSummary,
} from "@/api/inspections"; } from "@/api/inspections";
import { uploadPhoto } from "@/api/photos";
import { PhotoSlot } from "@/components/camera/PhotoSlot"; import { PhotoSlot } from "@/components/camera/PhotoSlot";
import { CameraCapture } from "@/components/camera/CameraCapture";
import { VendorVehicleScheme } from "@/components/vehicle-scheme/VendorVehicleScheme"; import { VendorVehicleScheme } from "@/components/vehicle-scheme/VendorVehicleScheme";
import { AddMarkerDialog } from "@/components/inspection/AddMarkerDialog";
import { TireWearDialog } from "@/components/inspection/TireWearDialog"; 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 { AuthImg } from "@/components/AuthImg";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { loadZoneLabels } from "@/data/zoneLabels";
const SLOTS: { side: string; label: string }[] = [ const SLOTS: { side: string; label: string }[] = [
{ side: "front", label: "Перед" }, { side: "front", label: "Перед" },
@@ -52,6 +58,9 @@ const DAMAGE_TYPE_LABELS: Record<string, string> = {
"not-working": "не работает", "not-working": "не работает",
}; };
// Vendor 3-position severity index → backend literal
const SEVERITY_MAP = ["minor", "moderate", "severe"] as const;
function humanizeMarkerSide(side: string | null | undefined): string { function humanizeMarkerSide(side: string | null | undefined): string {
if (!side) return "—"; if (!side) return "—";
if (side === "tires") return "Резина"; if (side === "tires") return "Резина";
@@ -79,15 +88,25 @@ function humanizeMarkerSide(side: string | null | undefined): string {
return map[side] ?? side; 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() { export default function InspectionEditor() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const insId = Number(id); const insId = Number(id);
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient(); 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); const [tireWearOpen, setTireWearOpen] = useState(false);
useEffect(() => { loadZoneLabels().then(setZoneLabels); }, []);
const { data: ins, isLoading, isError } = useQuery({ const { data: ins, isLoading, isError } = useQuery({
queryKey: ["inspection", insId], queryKey: ["inspection", insId],
queryFn: () => getInspection(insId), queryFn: () => getInspection(insId),
@@ -112,6 +131,11 @@ export default function InspectionEditor() {
const editable = ins.status === "in_progress"; const editable = ins.status === "in_progress";
const handleZoneTap = (zoneId: number) => {
const label = zoneLabels?.[String(zoneId)] ?? `зона ${zoneId}`;
setFlow({ kind: "confirm", zoneId, zoneLabel: label });
};
return ( return (
<div className="min-h-screen bg-background p-4 space-y-4"> <div className="min-h-screen bg-background p-4 space-y-4">
<button <button
@@ -143,24 +167,79 @@ export default function InspectionEditor() {
severity: m.severity, severity: m.severity,
resolved: m.resolved, resolved: m.resolved,
}))} }))}
onZoneTap={ onZoneTap={editable ? handleZoneTap : undefined}
editable ? (zid) => setPendingTap({ side: `zone-${zid}` }) : undefined
}
onTireWearClick={editable ? () => setTireWearOpen(true) : undefined} onTireWearClick={editable ? () => setTireWearOpen(true) : undefined}
editable={editable} editable={editable}
/> />
</Card> </Card>
{pendingTap && ( {/* Multi-step damage flow */}
<AddMarkerDialog {flow.kind === "confirm" && (
<ZoneConfirmDialog
open 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} inspectionId={insId}
side={pendingTap.side} zoneLabel={flow.zoneLabel}
onClose={() => setPendingTap(null)} onCancel={() => setFlow({ kind: "idle" })}
onCreated={() => { onPhotoUploaded={async (photoId) => {
setPendingTap(null); try {
void qc.invalidateQueries({ queryKey: ["inspection", insId] }); await createMarker(insId, {
toast.success("Метка добавлена"); 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> </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>
);
}