feat(mechanic-pwa): VehicleScheme + InspectionEditor + InspectionReview pages (Phase I4-I6)
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user