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 { 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 {
|
||||||
|
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] });
|
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
|
||||||
toast.success("Метка добавлена");
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user