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,
|
type MarkerSummary,
|
||||||
} from "@/api/inspections";
|
} from "@/api/inspections";
|
||||||
import { PhotoSlot } from "@/components/camera/PhotoSlot";
|
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 { AddMarkerDialog } from "@/components/inspection/AddMarkerDialog";
|
||||||
|
import { TireWearDialog } from "@/components/inspection/TireWearDialog";
|
||||||
import { AuthImg } from "@/components/AuthImg";
|
import { AuthImg } from "@/components/AuthImg";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
@@ -59,6 +64,11 @@ function humanizeMarkerSide(side: string | null | undefined): string {
|
|||||||
back: "Зад",
|
back: "Зад",
|
||||||
left: "Левый",
|
left: "Левый",
|
||||||
right: "Правый",
|
right: "Правый",
|
||||||
|
"bumper-fl": "Бампер ПЛ",
|
||||||
|
"bumper-fr": "Бампер ПП",
|
||||||
|
"bumper-rl": "Бампер ЗЛ",
|
||||||
|
"bumper-rr": "Бампер ЗП",
|
||||||
|
tires: "Резина",
|
||||||
// Legacy zone-* support (data from earlier prod)
|
// Legacy zone-* support (data from earlier prod)
|
||||||
"zone-front": "Передний бампер",
|
"zone-front": "Передний бампер",
|
||||||
"zone-rear": "Задний бампер",
|
"zone-rear": "Задний бампер",
|
||||||
@@ -78,10 +88,11 @@ export default function InspectionEditor() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const [pendingTap, setPendingTap] = useState<{
|
const [pendingTap, setPendingTap] = useState<{
|
||||||
view: ViewName;
|
view: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [tireWearOpen, setTireWearOpen] = useState(false);
|
||||||
|
|
||||||
const { data: ins, isLoading, isError } = useQuery({
|
const { data: ins, isLoading, isError } = useQuery({
|
||||||
queryKey: ["inspection", insId],
|
queryKey: ["inspection", insId],
|
||||||
@@ -130,7 +141,7 @@ export default function InspectionEditor() {
|
|||||||
<h2 className="text-sm font-semibold text-muted-foreground mb-3">
|
<h2 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||||
Схема — тапните на машину, чтобы добавить метку
|
Схема — тапните на машину, чтобы добавить метку
|
||||||
</h2>
|
</h2>
|
||||||
<MultiViewVehicleScheme
|
<CompositeVehicleScheme
|
||||||
markers={ins.markers.map((m: MarkerSummary) => ({
|
markers={ins.markers.map((m: MarkerSummary) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
side: m.side,
|
side: m.side,
|
||||||
@@ -140,7 +151,19 @@ export default function InspectionEditor() {
|
|||||||
damage_type: m.damage_type,
|
damage_type: m.damage_type,
|
||||||
resolved: m.resolved,
|
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}
|
editable={editable}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</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>
|
<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">
|
||||||
|
|||||||
@@ -2,7 +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 { CompositeVehicleScheme } from "@/components/vehicle-scheme/CompositeVehicleScheme";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
@@ -45,6 +45,11 @@ function humanizeMarkerSide(side: string | null | undefined): string {
|
|||||||
back: "Зад",
|
back: "Зад",
|
||||||
left: "Левый",
|
left: "Левый",
|
||||||
right: "Правый",
|
right: "Правый",
|
||||||
|
"bumper-fl": "Бампер ПЛ",
|
||||||
|
"bumper-fr": "Бампер ПП",
|
||||||
|
"bumper-rl": "Бампер ЗЛ",
|
||||||
|
"bumper-rr": "Бампер ЗП",
|
||||||
|
tires: "Резина",
|
||||||
// Legacy zone-* support (data from earlier prod)
|
// Legacy zone-* support (data from earlier prod)
|
||||||
"zone-front": "Передний бампер",
|
"zone-front": "Передний бампер",
|
||||||
"zone-rear": "Задний бампер",
|
"zone-rear": "Задний бампер",
|
||||||
@@ -109,12 +114,11 @@ export default function InspectionReview() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{ins.markers.length > 0 && (
|
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h2 className="text-sm font-semibold text-muted-foreground mb-3">
|
<h2 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||||
Схема повреждений
|
Схема повреждений
|
||||||
</h2>
|
</h2>
|
||||||
<MultiViewVehicleScheme
|
<CompositeVehicleScheme
|
||||||
markers={ins.markers.map((m: MarkerSummary) => ({
|
markers={ins.markers.map((m: MarkerSummary) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
side: m.side,
|
side: m.side,
|
||||||
@@ -127,7 +131,6 @@ export default function InspectionReview() {
|
|||||||
editable={false}
|
editable={false}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</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">
|
||||||
|
|||||||
Reference in New Issue
Block a user