feat(mechanic-pwa): VehicleScheme + InspectionEditor + InspectionReview pages (Phase I4-I6)

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-05-17 10:54:00 +10:00
co-authored by claude-flow
parent fc4060d4d9
commit a428395071
5 changed files with 547 additions and 10 deletions
+61 -2
View File
@@ -20,6 +20,34 @@ export interface InspectionCreateInput {
driver_id?: number;
}
export interface PhotoSummary {
id: number;
side: string;
slot_index: number;
storage_key: string;
thumb_key?: string | null;
width?: number | null;
height?: number | null;
status: string;
taken_at: string;
}
export interface MarkerSummary {
id: number;
inspection_id: number;
photo_id?: number | null;
side?: string | null;
x?: number | null;
y?: number | null;
polygon?: { x: number; y: number }[] | null;
damage_type?: string | null;
severity?: string | null;
description?: string | null;
carried_over_from_id?: number | null;
resolved: boolean;
created_at: string;
}
export interface InspectionDetail {
id: number;
vehicle_id: number;
@@ -32,8 +60,8 @@ export interface InspectionDetail {
notes?: string | null;
prev_inspection_id?: number | null;
meta: Record<string, unknown>;
photos: unknown[]; // typed properly in Phase I when photo UI is built
markers: unknown[];
photos: PhotoSummary[];
markers: MarkerSummary[];
}
export async function createInspection(
@@ -42,4 +70,35 @@ export async function createInspection(
return api.post("inspections", { json: input }).json<InspectionDetail>();
}
export async function getInspection(id: number): Promise<InspectionDetail> {
return api.get(`inspections/${id}`).json<InspectionDetail>();
}
export async function patchInspection(
id: number,
body: { status?: InspectionStatus; notes?: string; finished_at?: string }
): Promise<InspectionDetail> {
return api.patch(`inspections/${id}`, { json: body }).json<InspectionDetail>();
}
export interface MarkerCreateInput {
photo_id?: number | null;
side?: string | null;
x?: number | null;
y?: number | null;
polygon?: { x: number; y: number }[] | null;
damage_type?: string | null;
severity?: string | null;
description?: string | null;
}
export async function createMarker(
inspectionId: number,
body: MarkerCreateInput
): Promise<MarkerSummary> {
return api
.post(`inspections/${inspectionId}/markers`, { json: body })
.json<MarkerSummary>();
}
export type { InspectionSummary };
@@ -0,0 +1,110 @@
import { useState } from "react";
import sedanSrc from "./schemes/sedan-top.svg?raw";
interface MarkerDot {
x: number;
y: number;
severity?: string | null;
}
interface Props {
/** Normalized 0..1 dots overlaid on the scheme. */
markers?: MarkerDot[];
/** Click handler — receives the zone id like "zone-front". */
onZoneClick?: (zoneId: string) => void;
/** Per-zone count overlays (e.g. {"zone-front": 2}). Optional. */
zoneCounts?: Record<string, number>;
}
const ZONES = [
"zone-front",
"zone-rear",
"zone-left",
"zone-right",
"zone-hood",
"zone-trunk",
"zone-roof",
];
const ZONE_LABELS: Record<string, string> = {
"zone-front": "Передний бампер",
"zone-rear": "Задний бампер",
"zone-left": "Левый бок",
"zone-right": "Правый бок",
"zone-hood": "Капот",
"zone-trunk": "Багажник",
"zone-roof": "Крыша",
};
// Approximate badge position per zone (percentage of container)
const ZONE_BADGE_POSITIONS: Record<string, { x: string; y: string }> = {
"zone-front": { x: "50%", y: "10%" },
"zone-rear": { x: "50%", y: "90%" },
"zone-left": { x: "12%", y: "50%" },
"zone-right": { x: "88%", y: "50%" },
"zone-hood": { x: "50%", y: "10%" },
"zone-trunk": { x: "50%", y: "90%" },
"zone-roof": { x: "50%", y: "50%" },
};
export function VehicleScheme({ markers = [], onZoneClick, zoneCounts }: Props) {
const [hoverZone, setHoverZone] = useState<string | null>(null);
return (
<div className="relative w-full max-w-xs mx-auto">
<div
className="vehicle-scheme cursor-pointer"
dangerouslySetInnerHTML={{ __html: sedanSrc }}
onClick={(e) => {
const target = e.target as Element;
const id = target.getAttribute?.("id");
if (id && ZONES.includes(id)) onZoneClick?.(id);
}}
onMouseMove={(e) => {
const target = e.target as Element;
const id = target.getAttribute?.("id");
setHoverZone(id && ZONES.includes(id) ? id : null);
}}
onMouseLeave={() => setHoverZone(null)}
/>
{/* Point markers (red dots) overlaid on the scheme via normalized coords */}
{markers.map((m, i) => (
<div
key={i}
className="absolute w-3 h-3 rounded-full bg-destructive border border-white pointer-events-none"
style={{
left: `${m.x * 100}%`,
top: `${m.y * 100}%`,
transform: "translate(-50%, -50%)",
}}
/>
))}
{/* Zone counts as small badges */}
{zoneCounts &&
Object.entries(zoneCounts).map(([zone, count]) => {
if (count <= 0) return null;
const pos = ZONE_BADGE_POSITIONS[zone];
if (!pos) return null;
return (
<div
key={zone}
className="absolute bg-destructive text-destructive-foreground text-xs font-semibold rounded-full w-5 h-5 flex items-center justify-center pointer-events-none"
style={{
left: pos.x,
top: pos.y,
transform: "translate(-50%, -50%)",
}}
>
{count}
</div>
);
})}
<div className="text-xs text-center mt-1 text-muted-foreground min-h-[1em]">
{hoverZone ? ZONE_LABELS[hoverZone] : "Тапни по зоне, чтобы добавить метку"}
</div>
</div>
);
}
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 400">
<rect x="40" y="20" width="120" height="360" rx="40" ry="40"
fill="none" stroke="hsl(0 0% 16%)" stroke-width="2"/>
<rect x="55" y="60" width="90" height="60" rx="10" fill="hsl(200 30% 88%)"/>
<rect x="55" y="280" width="90" height="50" rx="10" fill="hsl(200 30% 88%)"/>
<rect x="55" y="130" width="90" height="140" fill="hsl(40 20% 92%)" stroke="hsl(0 0% 60%)" stroke-width="1"/>
<rect id="zone-front" x="40" y="20" width="120" height="40" fill="transparent"/>
<rect id="zone-rear" x="40" y="340" width="120" height="40" fill="transparent"/>
<rect id="zone-left" x="40" y="60" width="20" height="280" fill="transparent"/>
<rect id="zone-right" x="140" y="60" width="20" height="280" fill="transparent"/>
<rect id="zone-hood" x="55" y="20" width="90" height="40" fill="transparent"/>
<rect id="zone-trunk" x="55" y="340" width="90" height="40" fill="transparent"/>
<rect id="zone-roof" x="55" y="130" width="90" height="140" fill="transparent"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,9 +1,235 @@
import { useParams } from "react-router-dom";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
getInspection,
patchInspection,
createMarker,
type MarkerSummary,
} from "@/api/inspections";
import { PhotoSlot } from "@/components/camera/PhotoSlot";
import { VehicleScheme } from "@/components/vehicle-scheme/VehicleScheme";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
const SLOTS: { side: string; label: string }[] = [
{ side: "front", label: "Перед" },
{ side: "rear", label: "Зад" },
{ side: "left", label: "Левый бок" },
{ side: "right", label: "Правый бок" },
{ side: "vin", label: "VIN" },
{ side: "odometer", label: "Одометр" },
{ side: "interior", label: "Салон" },
{ side: "free", label: "Свободное" },
];
const SEVERITY_LABELS: Record<string, string> = {
cosmetic: "косметика",
minor: "лёгкое",
moderate: "среднее",
severe: "тяжёлое",
};
const DAMAGE_TYPE_LABELS: Record<string, string> = {
damaged: "повреждено",
scratch: "царапина",
chip: "скол",
dent: "вмятина",
crack: "трещина",
scuff: "затёртость",
burn: "прожог",
stain: "пятно",
bent: "погнуто",
torn: "порвано",
cut: "порез",
puncture: "прокол",
bulge: "грыжа",
missing: "отсутствует",
removed: "снято",
"not-working": "не работает",
};
function humanizeMarkerSide(side: string | null | undefined): string {
if (!side) return "—";
const zoneMap: Record<string, string> = {
"zone-front": "Передний бампер",
"zone-rear": "Задний бампер",
"zone-left": "Левый бок",
"zone-right": "Правый бок",
"zone-hood": "Капот",
"zone-trunk": "Багажник",
"zone-roof": "Крыша",
};
return zoneMap[side] ?? side;
}
export default function InspectionEditor() {
const { id } = useParams();
const { id } = useParams<{ id: string }>();
const insId = Number(id);
const navigate = useNavigate();
const qc = useQueryClient();
const { data: ins, isLoading, isError } = useQuery({
queryKey: ["inspection", insId],
queryFn: () => getInspection(insId),
});
const finalize = useMutation({
mutationFn: () => patchInspection(insId, { status: "completed" }),
onSuccess: () => {
toast.success("Осмотр завершён");
navigate(`/inspections/${insId}`);
},
onError: () => toast.error("Не удалось завершить"),
});
const addZoneMarker = useMutation({
mutationFn: (zone: string) =>
createMarker(insId, {
side: zone,
damage_type: "damaged",
severity: "minor",
description: "Метка со схемы",
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
toast.success("Метка добавлена");
},
onError: () => toast.error("Ошибка"),
});
if (isLoading) return <div className="p-8 text-muted-foreground">Загружаю</div>;
if (isError || !ins)
return <div className="p-8 text-destructive">Осмотр не найден.</div>;
// Photos lookup by side+slot for initialPhotoId
const photosBySide = new Map<string, typeof ins.photos[0]>();
for (const p of ins.photos) photosBySide.set(`${p.side}-${p.slot_index}`, p);
// Point markers (x/y populated) drawn as dots on the scheme
const dots = ins.markers
.filter((m: MarkerSummary) => m.x != null && m.y != null)
.map((m: MarkerSummary) => ({
x: Number(m.x),
y: Number(m.y),
severity: m.severity ?? "minor",
}));
// Zone counts (markers with side="zone-*", no x/y)
const zoneCounts: Record<string, number> = {};
for (const m of ins.markers) {
if (m.side?.startsWith("zone-")) {
zoneCounts[m.side] = (zoneCounts[m.side] ?? 0) + 1;
}
}
const editable = ins.status === "in_progress";
return (
<div className="container py-8">
<h1 className="text-2xl font-semibold mb-4">Осмотр #{id} редактирование</h1>
<div className="min-h-screen bg-background p-4 space-y-4">
<button
onClick={() => navigate(-1)}
className="text-sm text-muted-foreground hover:text-foreground"
>
Назад
</button>
<div>
<h1 className="text-xl font-semibold">
Осмотр #{ins.id}{" "}
<span className="text-muted-foreground text-base">({ins.type})</span>
</h1>
<div className="text-xs text-muted-foreground">
{new Date(ins.started_at).toLocaleString("ru-RU")}
</div>
</div>
<Card className="p-4">
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
Схема тапни по зоне, чтобы добавить метку
</h2>
<VehicleScheme
markers={dots}
zoneCounts={zoneCounts}
onZoneClick={editable ? (zone) => addZoneMarker.mutate(zone) : undefined}
/>
</Card>
<div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">Фото</h2>
<div className="grid grid-cols-2 gap-2">
{SLOTS.map((s) => (
<PhotoSlot
key={`${s.side}-0`}
inspectionId={insId}
side={s.side}
slotIndex={0}
label={s.label}
initialPhotoId={photosBySide.get(`${s.side}-0`)?.id}
onUploaded={() =>
void qc.invalidateQueries({ queryKey: ["inspection", insId] })
}
/>
))}
</div>
</div>
{ins.markers.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
Метки ({ins.markers.length})
</h2>
<ul className="space-y-1">
{ins.markers.map((m: MarkerSummary) => (
<li key={m.id}>
<Card className="p-3 text-sm">
<div className="flex items-center justify-between">
<div>
<span className="font-medium">
{humanizeMarkerSide(m.side)}
</span>
{" — "}
<span>
{m.damage_type
? (DAMAGE_TYPE_LABELS[m.damage_type] ?? m.damage_type)
: "—"}
</span>
{m.severity && (
<>
{" — "}
<span className="text-muted-foreground">
{SEVERITY_LABELS[m.severity] ?? m.severity}
</span>
</>
)}
</div>
{m.carried_over_from_id && (
<span className="text-xs text-muted-foreground">
перенесено
</span>
)}
</div>
{m.description && (
<div className="text-xs text-muted-foreground mt-1">
{m.description}
</div>
)}
</Card>
</li>
))}
</ul>
</div>
)}
{editable && (
<Button
className="w-full"
onClick={() => finalize.mutate()}
disabled={finalize.isPending}
>
{finalize.isPending ? "Завершаем…" : "Завершить осмотр"}
</Button>
)}
</div>
);
}
@@ -1,9 +1,136 @@
import { useParams } from "react-router-dom";
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 { Card } from "@/components/ui/card";
const STATUS_LABELS: Record<string, string> = {
in_progress: "В работе",
completed: "Завершён",
cancelled: "Отменён",
};
export default function InspectionReview() {
const { id } = useParams();
const { id } = useParams<{ id: string }>();
const insId = Number(id);
const navigate = useNavigate();
const { data: ins, isLoading, isError } = useQuery({
queryKey: ["inspection", insId],
queryFn: () => getInspection(insId),
});
if (isLoading) return <div className="p-8 text-muted-foreground">Загружаю</div>;
if (isError || !ins)
return <div className="p-8 text-destructive">Осмотр не найден.</div>;
return (
<div className="container py-8">
<h1 className="text-2xl font-semibold mb-4">Осмотр #{id}</h1>
<div className="min-h-screen bg-background p-4 space-y-4">
<button
onClick={() => navigate(-1)}
className="text-sm text-muted-foreground hover:text-foreground"
>
Назад
</button>
<h1 className="text-xl font-semibold">Осмотр #{ins.id}</h1>
<Card className="p-4 text-sm space-y-1">
<div>
<span className="font-semibold">Тип: </span>
{ins.type}
</div>
<div>
<span className="font-semibold">Статус: </span>
{STATUS_LABELS[ins.status] ?? ins.status}
</div>
<div>
<span className="font-semibold">Начат: </span>
{new Date(ins.started_at).toLocaleString("ru-RU")}
</div>
{ins.finished_at && (
<div>
<span className="font-semibold">Завершён: </span>
{new Date(ins.finished_at).toLocaleString("ru-RU")}
</div>
)}
{ins.notes && (
<div>
<span className="font-semibold">Заметки: </span>
{ins.notes}
</div>
)}
</Card>
<div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
Фото ({ins.photos.length})
</h2>
{ins.photos.length === 0 ? (
<div className="text-sm text-muted-foreground">Нет фото.</div>
) : (
<div className="grid grid-cols-2 gap-2">
{ins.photos.map((p: PhotoSummary) => (
<Card key={p.id} className="overflow-hidden">
<AuthImg
src={`photos/${p.id}/thumb`}
alt={p.side}
className="w-full aspect-[4/3] object-cover bg-muted"
/>
<div className="text-xs text-muted-foreground p-2">{p.side}</div>
</Card>
))}
</div>
)}
</div>
<div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
Метки ({ins.markers.length})
</h2>
{ins.markers.length === 0 ? (
<div className="text-sm text-muted-foreground">Нет меток.</div>
) : (
<ul className="space-y-1">
{ins.markers.map((m: MarkerSummary) => (
<li key={m.id}>
<Card className="p-3 text-sm">
<div className="flex items-center justify-between">
<div>
<span>{m.side ?? "—"}</span>
{" — "}
<span>{m.damage_type ?? "—"}</span>
{m.severity && (
<span className="text-muted-foreground">
{" — "}
{m.severity}
</span>
)}
</div>
<div className="flex items-center gap-2">
{m.resolved && (
<span className="text-xs text-muted-foreground">
устранено
</span>
)}
{m.carried_over_from_id && (
<span className="text-xs text-muted-foreground">
перенесено
</span>
)}
</div>
</div>
{m.description && (
<div className="text-xs text-muted-foreground mt-1">
{m.description}
</div>
)}
</Card>
</li>
))}
</ul>
)}
</div>
</div>
);
}