diff --git a/mechanic-pwa/frontend/public/scheme/back.png b/mechanic-pwa/frontend/public/scheme/back.png new file mode 100644 index 0000000..2fb060c Binary files /dev/null and b/mechanic-pwa/frontend/public/scheme/back.png differ diff --git a/mechanic-pwa/frontend/public/scheme/front.png b/mechanic-pwa/frontend/public/scheme/front.png new file mode 100644 index 0000000..2f25506 Binary files /dev/null and b/mechanic-pwa/frontend/public/scheme/front.png differ diff --git a/mechanic-pwa/frontend/public/scheme/left.png b/mechanic-pwa/frontend/public/scheme/left.png new file mode 100644 index 0000000..a623055 Binary files /dev/null and b/mechanic-pwa/frontend/public/scheme/left.png differ diff --git a/mechanic-pwa/frontend/public/scheme/right.png b/mechanic-pwa/frontend/public/scheme/right.png new file mode 100644 index 0000000..66ee856 Binary files /dev/null and b/mechanic-pwa/frontend/public/scheme/right.png differ diff --git a/mechanic-pwa/frontend/public/scheme/top.png b/mechanic-pwa/frontend/public/scheme/top.png new file mode 100644 index 0000000..63d16f0 Binary files /dev/null and b/mechanic-pwa/frontend/public/scheme/top.png differ diff --git a/mechanic-pwa/frontend/src/components/inspection/AddMarkerDialog.tsx b/mechanic-pwa/frontend/src/components/inspection/AddMarkerDialog.tsx new file mode 100644 index 0000000..cfa218e --- /dev/null +++ b/mechanic-pwa/frontend/src/components/inspection/AddMarkerDialog.tsx @@ -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(undefined); + const [uploading, setUploading] = useState(false); + + async function handlePhoto(file: File) { + setUploading(true); + try { + // Marker close-ups land in the "free" slot — backend has no slot-uniqueness constraint + const result = await uploadPhoto(inspectionId, "free", 0, file); + setPhotoId(result.id); + toast.success("Фото повреждения загружено"); + } catch { + toast.error("Ошибка загрузки фото"); + } finally { + setUploading(false); + } + } + + const createMut = useMutation({ + mutationFn: (body: MarkerCreateInput) => createMarker(inspectionId, body), + onSuccess: (marker) => { + onCreated(marker); + reset(); + }, + onError: () => toast.error("Не удалось добавить метку"), + }); + + function reset() { + setDamageType("scratch"); + setSeverity("minor"); + setDescription(""); + setPhotoId(undefined); + } + + function handleClose() { + reset(); + onClose(); + } + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > +
+
+

Новая метка

+

+ {view} · {(x * 100).toFixed(1)}%, {(y * 100).toFixed(1)}% +

+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + setDescription(e.target.value)} + placeholder="Например: правый угол, длина ~10см" + /> +
+ +
+ + {photoId ? ( +
+ + +
+ ) : ( + + )} +
+ +
+ + +
+
+
+ ); +} diff --git a/mechanic-pwa/frontend/src/components/vehicle-scheme/MultiViewVehicleScheme.tsx b/mechanic-pwa/frontend/src/components/vehicle-scheme/MultiViewVehicleScheme.tsx new file mode 100644 index 0000000..5add8e4 --- /dev/null +++ b/mechanic-pwa/frontend/src/components/vehicle-scheme/MultiViewVehicleScheme.tsx @@ -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("top"); + const imgWrapRef = useRef(null); + + const visibleMarkers = markers.filter( + (m) => m.side === active && m.x != null && m.y != null + ); + + function handleClick(e: React.MouseEvent) { + 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 ( +
+ {/* View tabs */} +
+ {VIEWS.map((v) => { + const count = markers.filter((m) => m.side === v.key).length; + return ( + + ); + })} +
+ + {/* Image + marker overlay */} +
+ 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) => ( + + ))} +
+ + {editable && ( +

+ Тапните на машину, чтобы поставить метку повреждения. +

+ )} +
+ ); +} diff --git a/mechanic-pwa/frontend/src/components/vehicle-scheme/VehicleScheme.tsx b/mechanic-pwa/frontend/src/components/vehicle-scheme/VehicleScheme.tsx deleted file mode 100644 index 45f0368..0000000 --- a/mechanic-pwa/frontend/src/components/vehicle-scheme/VehicleScheme.tsx +++ /dev/null @@ -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; -} - -const ZONES = [ - "zone-front", - "zone-rear", - "zone-left", - "zone-right", - "zone-hood", - "zone-trunk", - "zone-roof", -]; - -const ZONE_LABELS: Record = { - "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 = { - "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(null); - - return ( -
-
{ - 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) => ( -
- ))} - - {/* 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 ( -
- {count} -
- ); - })} - -
- {hoverZone ? ZONE_LABELS[hoverZone] : "Тапни по зоне, чтобы добавить метку"} -
-
- ); -} diff --git a/mechanic-pwa/frontend/src/components/vehicle-scheme/schemes/sedan-top.svg b/mechanic-pwa/frontend/src/components/vehicle-scheme/schemes/sedan-top.svg deleted file mode 100644 index 9c74c31..0000000 --- a/mechanic-pwa/frontend/src/components/vehicle-scheme/schemes/sedan-top.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - diff --git a/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx b/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx index 3b8e6ad..9fbea36 100644 --- a/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx +++ b/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx @@ -1,14 +1,16 @@ +import { useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { getInspection, patchInspection, - createMarker, type MarkerSummary, } from "@/api/inspections"; import { 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 { Card } from "@/components/ui/card"; @@ -51,7 +53,13 @@ const DAMAGE_TYPE_LABELS: Record = { function humanizeMarkerSide(side: string | null | undefined): string { if (!side) return "—"; - const zoneMap: Record = { + const map: Record = { + top: "Сверху", + front: "Перед", + back: "Зад", + left: "Левый", + right: "Правый", + // Legacy zone-* support (data from earlier prod) "zone-front": "Передний бампер", "zone-rear": "Задний бампер", "zone-left": "Левый бок", @@ -60,7 +68,7 @@ function humanizeMarkerSide(side: string | null | undefined): string { "zone-trunk": "Багажник", "zone-roof": "Крыша", }; - return zoneMap[side] ?? side; + return map[side] ?? side; } export default function InspectionEditor() { @@ -69,6 +77,12 @@ export default function InspectionEditor() { const navigate = useNavigate(); const qc = useQueryClient(); + const [pendingTap, setPendingTap] = useState<{ + view: ViewName; + x: number; + y: number; + } | null>(null); + const { data: ins, isLoading, isError } = useQuery({ queryKey: ["inspection", insId], queryFn: () => getInspection(insId), @@ -83,21 +97,6 @@ export default function InspectionEditor() { 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
Загружаю…
; if (isError || !ins) return
Осмотр не найден.
; @@ -106,23 +105,6 @@ export default function InspectionEditor() { const photosBySide = new Map(); 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 = {}; - 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"; return ( @@ -145,16 +127,40 @@ export default function InspectionEditor() {
-

- Схема — тапни по зоне, чтобы добавить метку +

+ Схема — тапните на машину, чтобы добавить метку

- addZoneMarker.mutate(zone) : undefined} + ({ + 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, + }))} + onTap={editable ? (view, x, y) => setPendingTap({ view, x, y }) : undefined} + editable={editable} />
+ {pendingTap && ( + setPendingTap(null)} + onCreated={() => { + setPendingTap(null); + void qc.invalidateQueries({ queryKey: ["inspection", insId] }); + toast.success("Метка добавлена"); + }} + /> + )} +

Фото

@@ -214,6 +220,15 @@ export default function InspectionEditor() { {m.description}
)} + {m.photo_id && ( +
+ +
+ )} ))} diff --git a/mechanic-pwa/frontend/src/pages/InspectionReview.tsx b/mechanic-pwa/frontend/src/pages/InspectionReview.tsx index af91fd9..e8f5bcb 100644 --- a/mechanic-pwa/frontend/src/pages/InspectionReview.tsx +++ b/mechanic-pwa/frontend/src/pages/InspectionReview.tsx @@ -2,6 +2,7 @@ import { useParams, useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { getInspection, type MarkerSummary, type PhotoSummary } from "@/api/inspections"; import { AuthImg } from "@/components/AuthImg"; +import { MultiViewVehicleScheme } from "@/components/vehicle-scheme/MultiViewVehicleScheme"; import { Card } from "@/components/ui/card"; const STATUS_LABELS: Record = { @@ -10,6 +11,52 @@ const STATUS_LABELS: Record = { cancelled: "Отменён", }; +const SEVERITY_LABELS: Record = { + cosmetic: "косметика", + minor: "лёгкое", + moderate: "среднее", + severe: "тяжёлое", +}; + +const DAMAGE_TYPE_LABELS: Record = { + 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 = { + 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() { const { id } = useParams<{ id: string }>(); const insId = Number(id); @@ -62,6 +109,26 @@ export default function InspectionReview() { )} + {ins.markers.length > 0 && ( + +

+ Схема повреждений +

+ ({ + 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} + /> +
+ )} +

Фото ({ins.photos.length}) @@ -97,14 +164,22 @@ export default function InspectionReview() {
- {m.side ?? "—"} + + {humanizeMarkerSide(m.side)} + {" — "} - {m.damage_type ?? "—"} + + {m.damage_type + ? (DAMAGE_TYPE_LABELS[m.damage_type] ?? m.damage_type) + : "—"} + {m.severity && ( - + <> {" — "} - {m.severity} - + + {SEVERITY_LABELS[m.severity] ?? m.severity} + + )}
@@ -125,6 +200,15 @@ export default function InspectionReview() { {m.description}
)} + {m.photo_id && ( +
+ +
+ )} ))}