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:
2026-05-17 19:41:13 +10:00
co-authored by claude-flow
parent 3c6dacb29b
commit df709c58e5
5 changed files with 569 additions and 167 deletions
@@ -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">