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;
|
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 {
|
export interface InspectionDetail {
|
||||||
id: number;
|
id: number;
|
||||||
vehicle_id: number;
|
vehicle_id: number;
|
||||||
@@ -32,8 +60,8 @@ export interface InspectionDetail {
|
|||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
prev_inspection_id?: number | null;
|
prev_inspection_id?: number | null;
|
||||||
meta: Record<string, unknown>;
|
meta: Record<string, unknown>;
|
||||||
photos: unknown[]; // typed properly in Phase I when photo UI is built
|
photos: PhotoSummary[];
|
||||||
markers: unknown[];
|
markers: MarkerSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createInspection(
|
export async function createInspection(
|
||||||
@@ -42,4 +70,35 @@ export async function createInspection(
|
|||||||
return api.post("inspections", { json: input }).json<InspectionDetail>();
|
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 };
|
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() {
|
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 (
|
return (
|
||||||
<div className="container py-8">
|
<div className="min-h-screen bg-background p-4 space-y-4">
|
||||||
<h1 className="text-2xl font-semibold mb-4">Осмотр #{id} — редактирование</h1>
|
<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>
|
</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() {
|
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 (
|
return (
|
||||||
<div className="container py-8">
|
<div className="min-h-screen bg-background p-4 space-y-4">
|
||||||
<h1 className="text-2xl font-semibold mb-4">Осмотр #{id}</h1>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user