feat(mechanic-pwa): multi-view vendor scheme, exact tap coords, per-marker photo upload
- Replace SVG sedan zone-click model with 5-view PNG scheme (top/front/back/left/right) - New MultiViewVehicleScheme: tab selector, tap-to-exact-coord marker placement, touchAction:manipulation prevents iOS double-tap zoom, marker dots at normalized x/y - New AddMarkerDialog: bottom-sheet modal with damage_type (16 opts), severity (4 opts), description, optional CameraCapture → uploadPhoto(side="free") → photo_id on submit - InspectionEditor: removes addZoneMarker mutation and zone-count machinery; wires pendingTap state → dialog open/close → query invalidation - InspectionReview: adds read-only MultiViewVehicleScheme; both pages show photo_id thumbnails via AuthImg in the marker list - humanizeMarkerSide updated for new view names; keeps zone-* legacy mapping - Deletes VehicleScheme.tsx and sedan-top.svg (no remaining imports) - No backend changes, no new dependencies Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,230 @@
|
|||||||
|
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: "Тяжёлое" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
inspectionId: number;
|
||||||
|
view: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (marker: MarkerSummary) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddMarkerDialog({
|
||||||
|
open,
|
||||||
|
inspectionId,
|
||||||
|
view,
|
||||||
|
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">
|
||||||
|
{view} · {(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: view,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
damage_type: damageType,
|
||||||
|
severity,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
photo_id: photoId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{createMut.isPending ? "Добавляю…" : "Добавить"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type ViewName = "top" | "front" | "back" | "left" | "right";
|
||||||
|
|
||||||
|
export interface SchemeMarker {
|
||||||
|
id: number;
|
||||||
|
side?: string | null;
|
||||||
|
x?: number | null;
|
||||||
|
y?: number | null;
|
||||||
|
severity?: string | null;
|
||||||
|
damage_type?: string | null;
|
||||||
|
resolved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
markers: SchemeMarker[];
|
||||||
|
onTap?: (view: ViewName, x: number, y: number) => void;
|
||||||
|
onMarkerClick?: (markerId: number) => void;
|
||||||
|
editable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEWS: { key: ViewName; label: string; src: string }[] = [
|
||||||
|
{ key: "top", label: "Сверху", src: "/scheme/top.png" },
|
||||||
|
{ key: "front", label: "Перед", src: "/scheme/front.png" },
|
||||||
|
{ key: "back", label: "Зад", src: "/scheme/back.png" },
|
||||||
|
{ key: "left", label: "Левый", src: "/scheme/left.png" },
|
||||||
|
{ key: "right", label: "Правый", src: "/scheme/right.png" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MultiViewVehicleScheme({
|
||||||
|
markers,
|
||||||
|
onTap,
|
||||||
|
onMarkerClick,
|
||||||
|
editable = true,
|
||||||
|
}: Props) {
|
||||||
|
const [active, setActive] = useState<ViewName>("top");
|
||||||
|
const imgWrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const visibleMarkers = markers.filter(
|
||||||
|
(m) => m.side === active && m.x != null && m.y != null
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||||
|
if (!editable) return;
|
||||||
|
// Don't intercept marker dot clicks — they have their own handler.
|
||||||
|
if ((e.target as HTMLElement).dataset.markerId) return;
|
||||||
|
|
||||||
|
const el = imgWrapRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const x = (e.clientX - rect.left) / rect.width;
|
||||||
|
const y = (e.clientY - rect.top) / rect.height;
|
||||||
|
// Clamp 0..1 (defensive — touch on edges might overshoot by sub-pixel)
|
||||||
|
const clampedX = Math.max(0, Math.min(1, x));
|
||||||
|
const clampedY = Math.max(0, Math.min(1, y));
|
||||||
|
onTap?.(active, clampedX, clampedY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* View tabs */}
|
||||||
|
<div className="flex gap-1 overflow-x-auto -mx-1 px-1">
|
||||||
|
{VIEWS.map((v) => {
|
||||||
|
const count = markers.filter((m) => m.side === v.key).length;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={v.key}
|
||||||
|
type="button"
|
||||||
|
variant={v.key === active ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActive(v.key)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{v.label}
|
||||||
|
{count > 0 && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-1.5 rounded-full px-1.5 text-xs",
|
||||||
|
v.key === active
|
||||||
|
? "bg-primary-foreground text-primary"
|
||||||
|
: "bg-destructive text-destructive-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image + marker overlay */}
|
||||||
|
<div
|
||||||
|
ref={imgWrapRef}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
"relative w-full max-w-md mx-auto aspect-square bg-muted rounded-lg overflow-hidden",
|
||||||
|
editable ? "cursor-crosshair" : "cursor-default"
|
||||||
|
)}
|
||||||
|
style={{ touchAction: "manipulation" }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={VIEWS.find((v) => v.key === active)!.src}
|
||||||
|
alt={active}
|
||||||
|
className="absolute inset-0 w-full h-full object-contain p-4"
|
||||||
|
style={{ imageRendering: "auto" }}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
{visibleMarkers.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
data-marker-id={m.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMarkerClick?.(m.id);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"absolute w-6 h-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow",
|
||||||
|
"flex items-center justify-center text-xs font-bold text-white",
|
||||||
|
m.resolved ? "bg-muted-foreground/70" : "bg-destructive"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: `${(m.x ?? 0) * 100}%`,
|
||||||
|
top: `${(m.y ?? 0) * 100}%`,
|
||||||
|
}}
|
||||||
|
title={[m.damage_type, m.severity].filter(Boolean).join(" / ")}
|
||||||
|
>
|
||||||
|
●
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editable && (
|
||||||
|
<p className="text-xs text-center text-muted-foreground">
|
||||||
|
Тапните на машину, чтобы поставить метку повреждения.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import sedanSrc from "./schemes/sedan-top.svg?raw";
|
|
||||||
|
|
||||||
interface MarkerDot {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
severity?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Normalized 0..1 dots overlaid on the scheme. */
|
|
||||||
markers?: MarkerDot[];
|
|
||||||
/** Click handler — receives the zone id like "zone-front". */
|
|
||||||
onZoneClick?: (zoneId: string) => void;
|
|
||||||
/** Per-zone count overlays (e.g. {"zone-front": 2}). Optional. */
|
|
||||||
zoneCounts?: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ZONES = [
|
|
||||||
"zone-front",
|
|
||||||
"zone-rear",
|
|
||||||
"zone-left",
|
|
||||||
"zone-right",
|
|
||||||
"zone-hood",
|
|
||||||
"zone-trunk",
|
|
||||||
"zone-roof",
|
|
||||||
];
|
|
||||||
|
|
||||||
const ZONE_LABELS: Record<string, string> = {
|
|
||||||
"zone-front": "Передний бампер",
|
|
||||||
"zone-rear": "Задний бампер",
|
|
||||||
"zone-left": "Левый бок",
|
|
||||||
"zone-right": "Правый бок",
|
|
||||||
"zone-hood": "Капот",
|
|
||||||
"zone-trunk": "Багажник",
|
|
||||||
"zone-roof": "Крыша",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Approximate badge position per zone (percentage of container)
|
|
||||||
const ZONE_BADGE_POSITIONS: Record<string, { x: string; y: string }> = {
|
|
||||||
"zone-front": { x: "50%", y: "10%" },
|
|
||||||
"zone-rear": { x: "50%", y: "90%" },
|
|
||||||
"zone-left": { x: "12%", y: "50%" },
|
|
||||||
"zone-right": { x: "88%", y: "50%" },
|
|
||||||
"zone-hood": { x: "50%", y: "10%" },
|
|
||||||
"zone-trunk": { x: "50%", y: "90%" },
|
|
||||||
"zone-roof": { x: "50%", y: "50%" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function VehicleScheme({ markers = [], onZoneClick, zoneCounts }: Props) {
|
|
||||||
const [hoverZone, setHoverZone] = useState<string | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full max-w-xs mx-auto">
|
|
||||||
<div
|
|
||||||
className="vehicle-scheme cursor-pointer"
|
|
||||||
dangerouslySetInnerHTML={{ __html: sedanSrc }}
|
|
||||||
onClick={(e) => {
|
|
||||||
const target = e.target as Element;
|
|
||||||
const id = target.getAttribute?.("id");
|
|
||||||
if (id && ZONES.includes(id)) onZoneClick?.(id);
|
|
||||||
}}
|
|
||||||
onMouseMove={(e) => {
|
|
||||||
const target = e.target as Element;
|
|
||||||
const id = target.getAttribute?.("id");
|
|
||||||
setHoverZone(id && ZONES.includes(id) ? id : null);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => setHoverZone(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Point markers (red dots) overlaid on the scheme via normalized coords */}
|
|
||||||
{markers.map((m, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="absolute w-3 h-3 rounded-full bg-destructive border border-white pointer-events-none"
|
|
||||||
style={{
|
|
||||||
left: `${m.x * 100}%`,
|
|
||||||
top: `${m.y * 100}%`,
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Zone counts as small badges */}
|
|
||||||
{zoneCounts &&
|
|
||||||
Object.entries(zoneCounts).map(([zone, count]) => {
|
|
||||||
if (count <= 0) return null;
|
|
||||||
const pos = ZONE_BADGE_POSITIONS[zone];
|
|
||||||
if (!pos) return null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={zone}
|
|
||||||
className="absolute bg-destructive text-destructive-foreground text-xs font-semibold rounded-full w-5 h-5 flex items-center justify-center pointer-events-none"
|
|
||||||
style={{
|
|
||||||
left: pos.x,
|
|
||||||
top: pos.y,
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div className="text-xs text-center mt-1 text-muted-foreground min-h-[1em]">
|
|
||||||
{hoverZone ? ZONE_LABELS[hoverZone] : "Тапни по зоне, чтобы добавить метку"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 400">
|
|
||||||
<rect x="40" y="20" width="120" height="360" rx="40" ry="40"
|
|
||||||
fill="none" stroke="hsl(0 0% 16%)" stroke-width="2"/>
|
|
||||||
<rect x="55" y="60" width="90" height="60" rx="10" fill="hsl(200 30% 88%)"/>
|
|
||||||
<rect x="55" y="280" width="90" height="50" rx="10" fill="hsl(200 30% 88%)"/>
|
|
||||||
<rect x="55" y="130" width="90" height="140" fill="hsl(40 20% 92%)" stroke="hsl(0 0% 60%)" stroke-width="1"/>
|
|
||||||
<rect id="zone-front" x="40" y="20" width="120" height="40" fill="transparent"/>
|
|
||||||
<rect id="zone-rear" x="40" y="340" width="120" height="40" fill="transparent"/>
|
|
||||||
<rect id="zone-left" x="40" y="60" width="20" height="280" fill="transparent"/>
|
|
||||||
<rect id="zone-right" x="140" y="60" width="20" height="280" fill="transparent"/>
|
|
||||||
<rect id="zone-hood" x="55" y="20" width="90" height="40" fill="transparent"/>
|
|
||||||
<rect id="zone-trunk" x="55" y="340" width="90" height="40" fill="transparent"/>
|
|
||||||
<rect id="zone-roof" x="55" y="130" width="90" height="140" fill="transparent"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,14 +1,16 @@
|
|||||||
|
import { 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 { PhotoSlot } from "@/components/camera/PhotoSlot";
|
import { PhotoSlot } from "@/components/camera/PhotoSlot";
|
||||||
import { VehicleScheme } from "@/components/vehicle-scheme/VehicleScheme";
|
import { MultiViewVehicleScheme, type ViewName } from "@/components/vehicle-scheme/MultiViewVehicleScheme";
|
||||||
|
import { AddMarkerDialog } from "@/components/inspection/AddMarkerDialog";
|
||||||
|
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";
|
||||||
|
|
||||||
@@ -51,7 +53,13 @@ const DAMAGE_TYPE_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
function humanizeMarkerSide(side: string | null | undefined): string {
|
function humanizeMarkerSide(side: string | null | undefined): string {
|
||||||
if (!side) return "—";
|
if (!side) return "—";
|
||||||
const zoneMap: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
|
top: "Сверху",
|
||||||
|
front: "Перед",
|
||||||
|
back: "Зад",
|
||||||
|
left: "Левый",
|
||||||
|
right: "Правый",
|
||||||
|
// Legacy zone-* support (data from earlier prod)
|
||||||
"zone-front": "Передний бампер",
|
"zone-front": "Передний бампер",
|
||||||
"zone-rear": "Задний бампер",
|
"zone-rear": "Задний бампер",
|
||||||
"zone-left": "Левый бок",
|
"zone-left": "Левый бок",
|
||||||
@@ -60,7 +68,7 @@ function humanizeMarkerSide(side: string | null | undefined): string {
|
|||||||
"zone-trunk": "Багажник",
|
"zone-trunk": "Багажник",
|
||||||
"zone-roof": "Крыша",
|
"zone-roof": "Крыша",
|
||||||
};
|
};
|
||||||
return zoneMap[side] ?? side;
|
return map[side] ?? side;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InspectionEditor() {
|
export default function InspectionEditor() {
|
||||||
@@ -69,6 +77,12 @@ export default function InspectionEditor() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const [pendingTap, setPendingTap] = useState<{
|
||||||
|
view: ViewName;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const { data: ins, isLoading, isError } = useQuery({
|
const { data: ins, isLoading, isError } = useQuery({
|
||||||
queryKey: ["inspection", insId],
|
queryKey: ["inspection", insId],
|
||||||
queryFn: () => getInspection(insId),
|
queryFn: () => getInspection(insId),
|
||||||
@@ -83,21 +97,6 @@ export default function InspectionEditor() {
|
|||||||
onError: () => toast.error("Не удалось завершить"),
|
onError: () => toast.error("Не удалось завершить"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const addZoneMarker = useMutation({
|
|
||||||
mutationFn: (zone: string) =>
|
|
||||||
createMarker(insId, {
|
|
||||||
side: zone,
|
|
||||||
damage_type: "damaged",
|
|
||||||
severity: "minor",
|
|
||||||
description: "Метка со схемы",
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
|
|
||||||
toast.success("Метка добавлена");
|
|
||||||
},
|
|
||||||
onError: () => toast.error("Ошибка"),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) return <div className="p-8 text-muted-foreground">Загружаю…</div>;
|
if (isLoading) return <div className="p-8 text-muted-foreground">Загружаю…</div>;
|
||||||
if (isError || !ins)
|
if (isError || !ins)
|
||||||
return <div className="p-8 text-destructive">Осмотр не найден.</div>;
|
return <div className="p-8 text-destructive">Осмотр не найден.</div>;
|
||||||
@@ -106,23 +105,6 @@ export default function InspectionEditor() {
|
|||||||
const photosBySide = new Map<string, typeof ins.photos[0]>();
|
const photosBySide = new Map<string, typeof ins.photos[0]>();
|
||||||
for (const p of ins.photos) photosBySide.set(`${p.side}-${p.slot_index}`, p);
|
for (const p of ins.photos) photosBySide.set(`${p.side}-${p.slot_index}`, p);
|
||||||
|
|
||||||
// Point markers (x/y populated) drawn as dots on the scheme
|
|
||||||
const dots = ins.markers
|
|
||||||
.filter((m: MarkerSummary) => m.x != null && m.y != null)
|
|
||||||
.map((m: MarkerSummary) => ({
|
|
||||||
x: Number(m.x),
|
|
||||||
y: Number(m.y),
|
|
||||||
severity: m.severity ?? "minor",
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Zone counts (markers with side="zone-*", no x/y)
|
|
||||||
const zoneCounts: Record<string, number> = {};
|
|
||||||
for (const m of ins.markers) {
|
|
||||||
if (m.side?.startsWith("zone-")) {
|
|
||||||
zoneCounts[m.side] = (zoneCounts[m.side] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const editable = ins.status === "in_progress";
|
const editable = ins.status === "in_progress";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -145,16 +127,40 @@ export default function InspectionEditor() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
<h2 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||||
Схема — тапни по зоне, чтобы добавить метку
|
Схема — тапните на машину, чтобы добавить метку
|
||||||
</h2>
|
</h2>
|
||||||
<VehicleScheme
|
<MultiViewVehicleScheme
|
||||||
markers={dots}
|
markers={ins.markers.map((m: MarkerSummary) => ({
|
||||||
zoneCounts={zoneCounts}
|
id: m.id,
|
||||||
onZoneClick={editable ? (zone) => addZoneMarker.mutate(zone) : undefined}
|
side: m.side,
|
||||||
|
x: m.x == null ? null : Number(m.x),
|
||||||
|
y: m.y == null ? null : Number(m.y),
|
||||||
|
severity: m.severity,
|
||||||
|
damage_type: m.damage_type,
|
||||||
|
resolved: m.resolved,
|
||||||
|
}))}
|
||||||
|
onTap={editable ? (view, x, y) => setPendingTap({ view, x, y }) : undefined}
|
||||||
|
editable={editable}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{pendingTap && (
|
||||||
|
<AddMarkerDialog
|
||||||
|
open
|
||||||
|
inspectionId={insId}
|
||||||
|
view={pendingTap.view}
|
||||||
|
x={pendingTap.x}
|
||||||
|
y={pendingTap.y}
|
||||||
|
onClose={() => setPendingTap(null)}
|
||||||
|
onCreated={() => {
|
||||||
|
setPendingTap(null);
|
||||||
|
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
|
||||||
|
toast.success("Метка добавлена");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">Фото</h2>
|
<h2 className="text-sm font-semibold text-muted-foreground mb-2">Фото</h2>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -214,6 +220,15 @@ export default function InspectionEditor() {
|
|||||||
{m.description}
|
{m.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{m.photo_id && (
|
||||||
|
<div className="mt-2 max-w-[8rem]">
|
||||||
|
<AuthImg
|
||||||
|
src={`photos/${m.photo_id}/thumb`}
|
||||||
|
alt="Фото повреждения"
|
||||||
|
className="w-full aspect-[4/3] object-cover rounded bg-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useParams, useNavigate } from "react-router-dom";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getInspection, type MarkerSummary, type PhotoSummary } from "@/api/inspections";
|
import { getInspection, type MarkerSummary, type PhotoSummary } from "@/api/inspections";
|
||||||
import { AuthImg } from "@/components/AuthImg";
|
import { AuthImg } from "@/components/AuthImg";
|
||||||
|
import { MultiViewVehicleScheme } from "@/components/vehicle-scheme/MultiViewVehicleScheme";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
@@ -10,6 +11,52 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
cancelled: "Отменён",
|
cancelled: "Отменён",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SEVERITY_LABELS: Record<string, string> = {
|
||||||
|
cosmetic: "косметика",
|
||||||
|
minor: "лёгкое",
|
||||||
|
moderate: "среднее",
|
||||||
|
severe: "тяжёлое",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAMAGE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
damaged: "повреждено",
|
||||||
|
scratch: "царапина",
|
||||||
|
chip: "скол",
|
||||||
|
dent: "вмятина",
|
||||||
|
crack: "трещина",
|
||||||
|
scuff: "затёртость",
|
||||||
|
burn: "прожог",
|
||||||
|
stain: "пятно",
|
||||||
|
bent: "погнуто",
|
||||||
|
torn: "порвано",
|
||||||
|
cut: "порез",
|
||||||
|
puncture: "прокол",
|
||||||
|
bulge: "грыжа",
|
||||||
|
missing: "отсутствует",
|
||||||
|
removed: "снято",
|
||||||
|
"not-working": "не работает",
|
||||||
|
};
|
||||||
|
|
||||||
|
function humanizeMarkerSide(side: string | null | undefined): string {
|
||||||
|
if (!side) return "—";
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
top: "Сверху",
|
||||||
|
front: "Перед",
|
||||||
|
back: "Зад",
|
||||||
|
left: "Левый",
|
||||||
|
right: "Правый",
|
||||||
|
// Legacy zone-* support (data from earlier prod)
|
||||||
|
"zone-front": "Передний бампер",
|
||||||
|
"zone-rear": "Задний бампер",
|
||||||
|
"zone-left": "Левый бок",
|
||||||
|
"zone-right": "Правый бок",
|
||||||
|
"zone-hood": "Капот",
|
||||||
|
"zone-trunk": "Багажник",
|
||||||
|
"zone-roof": "Крыша",
|
||||||
|
};
|
||||||
|
return map[side] ?? side;
|
||||||
|
}
|
||||||
|
|
||||||
export default function InspectionReview() {
|
export default function InspectionReview() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const insId = Number(id);
|
const insId = Number(id);
|
||||||
@@ -62,6 +109,26 @@ export default function InspectionReview() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{ins.markers.length > 0 && (
|
||||||
|
<Card className="p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||||
|
Схема повреждений
|
||||||
|
</h2>
|
||||||
|
<MultiViewVehicleScheme
|
||||||
|
markers={ins.markers.map((m: MarkerSummary) => ({
|
||||||
|
id: m.id,
|
||||||
|
side: m.side,
|
||||||
|
x: m.x == null ? null : Number(m.x),
|
||||||
|
y: m.y == null ? null : Number(m.y),
|
||||||
|
severity: m.severity,
|
||||||
|
damage_type: m.damage_type,
|
||||||
|
resolved: m.resolved,
|
||||||
|
}))}
|
||||||
|
editable={false}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||||
Фото ({ins.photos.length})
|
Фото ({ins.photos.length})
|
||||||
@@ -97,14 +164,22 @@ export default function InspectionReview() {
|
|||||||
<Card className="p-3 text-sm">
|
<Card className="p-3 text-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span>{m.side ?? "—"}</span>
|
<span className="font-medium">
|
||||||
{" — "}
|
{humanizeMarkerSide(m.side)}
|
||||||
<span>{m.damage_type ?? "—"}</span>
|
|
||||||
{m.severity && (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{" — "}
|
|
||||||
{m.severity}
|
|
||||||
</span>
|
</span>
|
||||||
|
{" — "}
|
||||||
|
<span>
|
||||||
|
{m.damage_type
|
||||||
|
? (DAMAGE_TYPE_LABELS[m.damage_type] ?? m.damage_type)
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
|
{m.severity && (
|
||||||
|
<>
|
||||||
|
{" — "}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{SEVERITY_LABELS[m.severity] ?? m.severity}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -125,6 +200,15 @@ export default function InspectionReview() {
|
|||||||
{m.description}
|
{m.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{m.photo_id && (
|
||||||
|
<div className="mt-2 max-w-[8rem]">
|
||||||
|
<AuthImg
|
||||||
|
src={`photos/${m.photo_id}/thumb`}
|
||||||
|
alt="Фото повреждения"
|
||||||
|
className="w-full aspect-[4/3] object-cover rounded bg-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user