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 <ruv@ruv.net>
This commit is contained in:
@@ -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<string>("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 {
|
||||
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 (
|
||||
<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()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Состояние резины</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="text-muted-foreground hover:text-foreground text-2xl leading-none"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Wear level — radio cards for large touch targets */}
|
||||
<div className="space-y-2">
|
||||
<Label>Уровень износа</Label>
|
||||
<div className="space-y-2">
|
||||
{WEAR_LEVELS.map((w) => (
|
||||
<label
|
||||
key={w.value}
|
||||
className={
|
||||
"flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors " +
|
||||
(level === w.value
|
||||
? "border-primary bg-accent"
|
||||
: "border-input hover:bg-accent/50")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="tire_wear"
|
||||
value={w.value}
|
||||
checked={level === w.value}
|
||||
onChange={() => setLevel(w.value)}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-sm">{w.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{w.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional comment */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tire_comment">Комментарий (опционально)</Label>
|
||||
<Input
|
||||
id="tire_comment"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Например: глубина протектора ~3 мм"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Optional photo */}
|
||||
<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>
|
||||
|
||||
{/* Actions */}
|
||||
<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()}
|
||||
className="flex-1"
|
||||
>
|
||||
{createMut.isPending ? "Сохраняю…" : "Сохранить"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<ViewName, HTMLDivElement | null>>({
|
||||
top: null,
|
||||
front: null,
|
||||
back: null,
|
||||
left: null,
|
||||
right: null,
|
||||
});
|
||||
|
||||
function handleViewTap(view: ViewName, e: React.MouseEvent<HTMLDivElement>) {
|
||||
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<BumperCornerName, number> = {
|
||||
"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 (
|
||||
<div className="space-y-3">
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<div
|
||||
className="mx-auto w-full max-w-md bg-card border rounded-xl p-3 select-none"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateAreas: `
|
||||
"wfl blf front brf wfr"
|
||||
". . . . ."
|
||||
"left . top . right"
|
||||
". . . . ."
|
||||
"wrl blr back brr wrr"
|
||||
`,
|
||||
gridTemplateRows: "auto 0.35rem auto 0.35rem auto",
|
||||
gridTemplateColumns: "2.5rem 2.75rem 1fr 2.75rem 2.5rem",
|
||||
gap: "0.4rem",
|
||||
touchAction: "manipulation",
|
||||
}}
|
||||
>
|
||||
{/* Decorative wheels — 4 corners, not clickable */}
|
||||
{WHEEL_AREAS.map((w) => (
|
||||
<div
|
||||
key={w}
|
||||
style={{ gridArea: w }}
|
||||
className="w-9 h-9 rounded-full bg-foreground/80 border-[3px] border-foreground/25 self-center justify-self-center"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Bumper corner sun-zones — clickable */}
|
||||
{BUMPER_AREAS.map((b) => (
|
||||
<button
|
||||
key={b.key}
|
||||
type="button"
|
||||
disabled={!editable}
|
||||
onClick={() => onBumperTap?.(b.key)}
|
||||
style={{ gridArea: b.gridArea }}
|
||||
className={cn(
|
||||
"relative w-11 h-11 self-center justify-self-center flex items-center justify-center",
|
||||
"transition-transform duration-100",
|
||||
editable && "hover:scale-110 active:scale-95 cursor-pointer",
|
||||
!editable && "cursor-default"
|
||||
)}
|
||||
aria-label={`Бампер ${b.label}`}
|
||||
>
|
||||
{/* 10-point star SVG (sun shape) */}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon
|
||||
points="50,4 61,35 93,39 70,60 79,93 50,76 21,93 30,60 7,39 39,35"
|
||||
fill="rgb(250 204 21)"
|
||||
stroke="rgb(180 120 0)"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
</svg>
|
||||
<span className="relative z-10 text-[10px] font-bold text-foreground leading-none">
|
||||
{b.label}
|
||||
</span>
|
||||
{bumperCounts[b.key] > 0 && (
|
||||
<span className="absolute -top-1 -right-1 z-20 bg-destructive text-destructive-foreground text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center border border-white shadow">
|
||||
{bumperCounts[b.key]}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* 5 PNG view tap targets */}
|
||||
{VIEW_AREAS.map((v) => {
|
||||
// Aspect ratio per view — chosen to approximate actual PNG dimensions
|
||||
// top: 128×128 → square; front: 69×50 → ~4:3 landscape; back: 293×201 → ~3:2;
|
||||
// left: 108×44 → ~5:2 strip; right: 109×44 → ~5:2 strip
|
||||
const aspectStyle: React.CSSProperties =
|
||||
v.key === "top" ? { aspectRatio: "1 / 1.25" } : // slightly taller — gives more tap surface
|
||||
v.key === "front" ? { aspectRatio: "4 / 3" } :
|
||||
v.key === "back" ? { aspectRatio: "4 / 3" } :
|
||||
/* left / right */ { aspectRatio: "5 / 2" };
|
||||
|
||||
return (
|
||||
<div
|
||||
key={v.key}
|
||||
ref={(el) => {
|
||||
viewRefs.current[v.key] = el;
|
||||
}}
|
||||
style={{ gridArea: v.gridArea, ...aspectStyle }}
|
||||
onClick={(e) => handleViewTap(v.key, e)}
|
||||
className={cn(
|
||||
"relative bg-muted/40 rounded-lg overflow-hidden w-full",
|
||||
editable && "cursor-crosshair"
|
||||
)}
|
||||
role={editable ? "button" : undefined}
|
||||
aria-label={editable ? `Добавить метку — ${v.label}` : v.label}
|
||||
>
|
||||
<img
|
||||
src={v.src}
|
||||
alt={v.label}
|
||||
draggable={false}
|
||||
className="absolute inset-0 w-full h-full object-contain p-1.5"
|
||||
style={{ imageRendering: "auto" }}
|
||||
/>
|
||||
|
||||
{/* Marker dots for this view */}
|
||||
{markers
|
||||
.filter(
|
||||
(m) => m.side === v.key && m.x != null && m.y != null
|
||||
)
|
||||
.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
data-marker-id={m.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkerClick?.(m.id);
|
||||
}}
|
||||
className={cn(
|
||||
"absolute w-5 h-5 -translate-x-1/2 -translate-y-1/2 rounded-full",
|
||||
"border-2 border-white shadow-md",
|
||||
"flex items-center justify-center text-[9px] font-bold text-white",
|
||||
"transition-transform hover:scale-125",
|
||||
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(" / ") ||
|
||||
undefined
|
||||
}
|
||||
>
|
||||
●
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Global tire-wear control */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTireWearClick}
|
||||
disabled={!onTireWearClick}
|
||||
className={cn(
|
||||
"w-full max-w-md mx-auto flex items-center justify-between gap-3",
|
||||
"rounded-xl border bg-card px-4 py-3",
|
||||
"text-sm font-medium",
|
||||
onTireWearClick
|
||||
? "hover:bg-accent active:bg-accent/80 transition-colors cursor-pointer"
|
||||
: "cursor-default opacity-80"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{/* Simple wheel / tyre icon using SVG so we don't need an emoji */}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="w-5 h-5 shrink-0 text-foreground/70"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<circle cx="12" cy="12" r="3.5" />
|
||||
<line x1="12" y1="3" x2="12" y2="8.5" />
|
||||
<line x1="12" y1="15.5" x2="12" y2="21" />
|
||||
<line x1="3" y1="12" x2="8.5" y2="12" />
|
||||
<line x1="15.5" y1="12" x2="21" y2="12" />
|
||||
</svg>
|
||||
<span>Состояние резины</span>
|
||||
</span>
|
||||
{tireCount > 0 && (
|
||||
<span className="bg-destructive text-destructive-foreground text-xs font-bold rounded-full px-2 py-0.5 shrink-0">
|
||||
{tireCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{editable && (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Тапните по части машины, чтобы добавить метку повреждения.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||
Схема — тапните на машину, чтобы добавить метку
|
||||
</h2>
|
||||
<MultiViewVehicleScheme
|
||||
<CompositeVehicleScheme
|
||||
markers={ins.markers.map((m: MarkerSummary) => ({
|
||||
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}
|
||||
/>
|
||||
</Card>
|
||||
@@ -161,6 +184,17 @@ export default function InspectionEditor() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<TireWearDialog
|
||||
open={tireWearOpen}
|
||||
inspectionId={insId}
|
||||
onClose={() => setTireWearOpen(false)}
|
||||
onCreated={() => {
|
||||
setTireWearOpen(false);
|
||||
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
|
||||
toast.success("Состояние резины записано");
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">Фото</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
||||
@@ -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<string, string> = {
|
||||
@@ -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() {
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
<Card className="p-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||
Схема повреждений
|
||||
</h2>
|
||||
<CompositeVehicleScheme
|
||||
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>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
|
||||
Reference in New Issue
Block a user