From df709c58e57b1878532e9601f55a2b0edec037d0 Mon Sep 17 00:00:00 2001 From: vladtechno Date: Sun, 17 May 2026 19:41:13 +1000 Subject: [PATCH] feat(mechanic-pwa): composite unfolded scheme + tire wear dialog (replaces 5-tab view) Single-screen grid layout showing all 5 PNG views + 4 bumper corner sun-zones + decorative wheels at once. Tap any view for precise AddMarkerDialog; tap bumper corners for center-point marker; global Tire Wear button opens new TireWearDialog (4-level radio cards). Deletes MultiViewVehicleScheme. No backend changes. humanizeMarkerSide covers bumper-* and tires. Co-Authored-By: claude-flow --- .../components/inspection/TireWearDialog.tsx | 217 +++++++++++++ .../vehicle-scheme/CompositeVehicleScheme.tsx | 291 ++++++++++++++++++ .../vehicle-scheme/MultiViewVehicleScheme.tsx | 143 --------- .../frontend/src/pages/InspectionEditor.tsx | 42 ++- .../frontend/src/pages/InspectionReview.tsx | 43 +-- 5 files changed, 569 insertions(+), 167 deletions(-) create mode 100644 mechanic-pwa/frontend/src/components/inspection/TireWearDialog.tsx create mode 100644 mechanic-pwa/frontend/src/components/vehicle-scheme/CompositeVehicleScheme.tsx delete mode 100644 mechanic-pwa/frontend/src/components/vehicle-scheme/MultiViewVehicleScheme.tsx diff --git a/mechanic-pwa/frontend/src/components/inspection/TireWearDialog.tsx b/mechanic-pwa/frontend/src/components/inspection/TireWearDialog.tsx new file mode 100644 index 0000000..42ef006 --- /dev/null +++ b/mechanic-pwa/frontend/src/components/inspection/TireWearDialog.tsx @@ -0,0 +1,217 @@ +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 MarkerSummary } from "@/api/inspections"; + +const WEAR_LEVELS: { value: string; label: string; description: string }[] = [ + { + value: "cosmetic", + label: "Новая", + description: "Протектор полный, без признаков износа", + }, + { + value: "minor", + label: "Норма", + description: "Естественный износ, эксплуатация в норме", + }, + { + value: "moderate", + label: "Износ", + description: "Заметный износ, требует наблюдения", + }, + { + value: "severe", + label: "Менять", + description: "Критический износ, замена необходима", + }, +]; + +interface Props { + open: boolean; + inspectionId: number; + onClose: () => void; + onCreated: (marker: MarkerSummary) => void; +} + +export function TireWearDialog({ + open, + inspectionId, + onClose, + onCreated, +}: Props) { + const [level, setLevel] = useState("minor"); + const [description, setDescription] = useState(""); + const [photoId, setPhotoId] = useState(undefined); + const [uploading, setUploading] = useState(false); + + async function handlePhoto(file: File) { + setUploading(true); + try { + const result = await uploadPhoto(inspectionId, "free", 0, file); + setPhotoId(result.id); + toast.success("Фото загружено"); + } catch { + toast.error("Ошибка загрузки фото"); + } finally { + setUploading(false); + } + } + + const createMut = useMutation({ + mutationFn: () => + createMarker(inspectionId, { + side: "tires", + severity: level, + // "not-working" is the closest existing damage_type enum value. + // The semantically meaningful field here is `severity` (wear level). + damage_type: "not-working", + description: + description.trim() || + `Состояние резины: ${WEAR_LEVELS.find((w) => w.value === level)?.label ?? level}`, + photo_id: photoId, + x: null, + y: null, + }), + onSuccess: (marker) => { + onCreated(marker); + reset(); + }, + onError: () => toast.error("Не удалось сохранить"), + }); + + function reset() { + setLevel("minor"); + setDescription(""); + setPhotoId(undefined); + } + + function handleClose() { + reset(); + onClose(); + } + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

Состояние резины

+ +
+ + {/* Wear level — radio cards for large touch targets */} +
+ +
+ {WEAR_LEVELS.map((w) => ( + + ))} +
+
+ + {/* Optional comment */} +
+ + setDescription(e.target.value)} + placeholder="Например: глубина протектора ~3 мм" + /> +
+ + {/* Optional photo */} +
+ + {photoId ? ( +
+ + +
+ ) : ( + + )} +
+ + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/mechanic-pwa/frontend/src/components/vehicle-scheme/CompositeVehicleScheme.tsx b/mechanic-pwa/frontend/src/components/vehicle-scheme/CompositeVehicleScheme.tsx new file mode 100644 index 0000000..9f78921 --- /dev/null +++ b/mechanic-pwa/frontend/src/components/vehicle-scheme/CompositeVehicleScheme.tsx @@ -0,0 +1,291 @@ +import { useRef } from "react"; +import { cn } from "@/lib/utils"; + +export type ViewName = "top" | "front" | "back" | "left" | "right"; +export type BumperCornerName = "bumper-fl" | "bumper-fr" | "bumper-rl" | "bumper-rr"; +export type SchemeSide = ViewName | BumperCornerName | "tires"; + +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[]; + onViewTap?: (view: ViewName, x: number, y: number) => void; + onBumperTap?: (corner: BumperCornerName) => void; + onTireWearClick?: () => void; + onMarkerClick?: (markerId: number) => void; + editable?: boolean; +} + +const VIEW_AREAS: { key: ViewName; gridArea: string; src: string; label: string }[] = [ + { key: "front", gridArea: "front", src: "/scheme/front.png", label: "Перед" }, + { key: "left", gridArea: "left", src: "/scheme/left.png", label: "Лево" }, + { key: "top", gridArea: "top", src: "/scheme/top.png", label: "Сверху" }, + { key: "right", gridArea: "right", src: "/scheme/right.png", label: "Право" }, + { key: "back", gridArea: "back", src: "/scheme/back.png", label: "Зад" }, +]; + +const BUMPER_AREAS: { + key: BumperCornerName; + gridArea: string; + label: string; +}[] = [ + { key: "bumper-fl", gridArea: "blf", label: "ПЛ" }, + { key: "bumper-fr", gridArea: "brf", label: "ПП" }, + { key: "bumper-rl", gridArea: "blr", label: "ЗЛ" }, + { key: "bumper-rr", gridArea: "brr", label: "ЗП" }, +]; + +// Wheel areas (decorative, 4 corners) +const WHEEL_AREAS = ["wfl", "wfr", "wrl", "wrr"] as const; + +export function CompositeVehicleScheme({ + markers, + onViewTap, + onBumperTap, + onTireWearClick, + onMarkerClick, + editable = true, +}: Props) { + const viewRefs = useRef>({ + top: null, + front: null, + back: null, + left: null, + right: null, + }); + + function handleViewTap(view: ViewName, e: React.MouseEvent) { + if (!editable) return; + if ((e.target as HTMLElement).dataset.markerId) return; + const el = viewRefs.current[view]; + if (!el) return; + const rect = el.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); + onViewTap?.(view, x, y); + } + + const bumperCounts: Record = { + "bumper-fl": 0, + "bumper-fr": 0, + "bumper-rl": 0, + "bumper-rr": 0, + }; + for (const m of markers) { + if (m.side && m.side in bumperCounts) { + bumperCounts[m.side as BumperCornerName] += 1; + } + } + const tireCount = markers.filter((m) => m.side === "tires").length; + + return ( +
+ {/* + Grid layout — 5 cols × 5 rows (named areas): + Row 1: wfl blf front brf wfr + Row 2: . . . . . (gap row) + Row 3: left . top . right + Row 4: . . . . . (gap row) + Row 5: wrl blr back brr wrr + + Columns: [wheel] [bumper] [main view — 1fr] [bumper] [wheel] + Left/right side views sit in col 1 / col 5 of row 3 (replacing wheels there). + Actual wheel decorations sit in cols 1&5 of rows 1&5. + */} +
+ {/* Decorative wheels — 4 corners, not clickable */} + {WHEEL_AREAS.map((w) => ( + + + {/* Global tire-wear control */} + + + {editable && ( +

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

+ )} +
+ ); +} diff --git a/mechanic-pwa/frontend/src/components/vehicle-scheme/MultiViewVehicleScheme.tsx b/mechanic-pwa/frontend/src/components/vehicle-scheme/MultiViewVehicleScheme.tsx deleted file mode 100644 index 5add8e4..0000000 --- a/mechanic-pwa/frontend/src/components/vehicle-scheme/MultiViewVehicleScheme.tsx +++ /dev/null @@ -1,143 +0,0 @@ -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/pages/InspectionEditor.tsx b/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx index 9fbea36..c20e5df 100644 --- a/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx +++ b/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx @@ -8,8 +8,13 @@ import { type MarkerSummary, } from "@/api/inspections"; import { PhotoSlot } from "@/components/camera/PhotoSlot"; -import { MultiViewVehicleScheme, type ViewName } from "@/components/vehicle-scheme/MultiViewVehicleScheme"; +import { + CompositeVehicleScheme, + type ViewName, + type BumperCornerName, +} from "@/components/vehicle-scheme/CompositeVehicleScheme"; import { AddMarkerDialog } from "@/components/inspection/AddMarkerDialog"; +import { TireWearDialog } from "@/components/inspection/TireWearDialog"; import { AuthImg } from "@/components/AuthImg"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -59,6 +64,11 @@ function humanizeMarkerSide(side: string | null | undefined): string { back: "Зад", left: "Левый", right: "Правый", + "bumper-fl": "Бампер ПЛ", + "bumper-fr": "Бампер ПП", + "bumper-rl": "Бампер ЗЛ", + "bumper-rr": "Бампер ЗП", + tires: "Резина", // Legacy zone-* support (data from earlier prod) "zone-front": "Передний бампер", "zone-rear": "Задний бампер", @@ -78,10 +88,11 @@ export default function InspectionEditor() { const qc = useQueryClient(); const [pendingTap, setPendingTap] = useState<{ - view: ViewName; + view: string; x: number; y: number; } | null>(null); + const [tireWearOpen, setTireWearOpen] = useState(false); const { data: ins, isLoading, isError } = useQuery({ queryKey: ["inspection", insId], @@ -130,7 +141,7 @@ export default function InspectionEditor() {

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

- ({ id: m.id, side: m.side, @@ -140,7 +151,19 @@ export default function InspectionEditor() { damage_type: m.damage_type, resolved: m.resolved, }))} - onTap={editable ? (view, x, y) => setPendingTap({ view, x, y }) : undefined} + onViewTap={ + editable + ? (view: ViewName, x: number, y: number) => + setPendingTap({ view, x, y }) + : undefined + } + onBumperTap={ + editable + ? (corner: BumperCornerName) => + setPendingTap({ view: corner, x: 0.5, y: 0.5 }) + : undefined + } + onTireWearClick={editable ? () => setTireWearOpen(true) : undefined} editable={editable} /> @@ -161,6 +184,17 @@ export default function InspectionEditor() { /> )} + setTireWearOpen(false)} + onCreated={() => { + setTireWearOpen(false); + void qc.invalidateQueries({ queryKey: ["inspection", insId] }); + toast.success("Состояние резины записано"); + }} + /> +

Фото

diff --git a/mechanic-pwa/frontend/src/pages/InspectionReview.tsx b/mechanic-pwa/frontend/src/pages/InspectionReview.tsx index e8f5bcb..073fef6 100644 --- a/mechanic-pwa/frontend/src/pages/InspectionReview.tsx +++ b/mechanic-pwa/frontend/src/pages/InspectionReview.tsx @@ -2,7 +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 { CompositeVehicleScheme } from "@/components/vehicle-scheme/CompositeVehicleScheme"; import { Card } from "@/components/ui/card"; const STATUS_LABELS: Record = { @@ -45,6 +45,11 @@ function humanizeMarkerSide(side: string | null | undefined): string { back: "Зад", left: "Левый", right: "Правый", + "bumper-fl": "Бампер ПЛ", + "bumper-fr": "Бампер ПП", + "bumper-rl": "Бампер ЗЛ", + "bumper-rr": "Бампер ЗП", + tires: "Резина", // Legacy zone-* support (data from earlier prod) "zone-front": "Передний бампер", "zone-rear": "Задний бампер", @@ -109,25 +114,23 @@ 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} - /> -
- )} + +

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

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