diff --git a/mechanic-pwa/frontend/src/api/inspections.ts b/mechanic-pwa/frontend/src/api/inspections.ts index c28399d..1b8ea5c 100644 --- a/mechanic-pwa/frontend/src/api/inspections.ts +++ b/mechanic-pwa/frontend/src/api/inspections.ts @@ -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; - 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(); } +export async function getInspection(id: number): Promise { + return api.get(`inspections/${id}`).json(); +} + +export async function patchInspection( + id: number, + body: { status?: InspectionStatus; notes?: string; finished_at?: string } +): Promise { + return api.patch(`inspections/${id}`, { json: body }).json(); +} + +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 { + return api + .post(`inspections/${inspectionId}/markers`, { json: body }) + .json(); +} + export type { InspectionSummary }; diff --git a/mechanic-pwa/frontend/src/components/vehicle-scheme/VehicleScheme.tsx b/mechanic-pwa/frontend/src/components/vehicle-scheme/VehicleScheme.tsx new file mode 100644 index 0000000..45f0368 --- /dev/null +++ b/mechanic-pwa/frontend/src/components/vehicle-scheme/VehicleScheme.tsx @@ -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; +} + +const ZONES = [ + "zone-front", + "zone-rear", + "zone-left", + "zone-right", + "zone-hood", + "zone-trunk", + "zone-roof", +]; + +const ZONE_LABELS: Record = { + "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 = { + "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(null); + + return ( +
+
{ + 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) => ( +
+ ))} + + {/* 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 ( +
+ {count} +
+ ); + })} + +
+ {hoverZone ? ZONE_LABELS[hoverZone] : "Тапни по зоне, чтобы добавить метку"} +
+
+ ); +} diff --git a/mechanic-pwa/frontend/src/components/vehicle-scheme/schemes/sedan-top.svg b/mechanic-pwa/frontend/src/components/vehicle-scheme/schemes/sedan-top.svg new file mode 100644 index 0000000..9c74c31 --- /dev/null +++ b/mechanic-pwa/frontend/src/components/vehicle-scheme/schemes/sedan-top.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx b/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx index 3cd0395..3b8e6ad 100644 --- a/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx +++ b/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx @@ -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 = { + cosmetic: "косметика", + minor: "лёгкое", + moderate: "среднее", + severe: "тяжёлое", +}; + +const DAMAGE_TYPE_LABELS: Record = { + 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 = { + "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
Загружаю…
; + if (isError || !ins) + return
Осмотр не найден.
; + + // Photos lookup by side+slot for initialPhotoId + const photosBySide = new Map(); + 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 = {}; + 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 ( -
-

Осмотр #{id} — редактирование

+
+ + +
+

+ Осмотр #{ins.id}{" "} + ({ins.type}) +

+
+ {new Date(ins.started_at).toLocaleString("ru-RU")} +
+
+ + +

+ Схема — тапни по зоне, чтобы добавить метку +

+ addZoneMarker.mutate(zone) : undefined} + /> +
+ +
+

Фото

+
+ {SLOTS.map((s) => ( + + void qc.invalidateQueries({ queryKey: ["inspection", insId] }) + } + /> + ))} +
+
+ + {ins.markers.length > 0 && ( +
+

+ Метки ({ins.markers.length}) +

+
    + {ins.markers.map((m: MarkerSummary) => ( +
  • + +
    +
    + + {humanizeMarkerSide(m.side)} + + {" — "} + + {m.damage_type + ? (DAMAGE_TYPE_LABELS[m.damage_type] ?? m.damage_type) + : "—"} + + {m.severity && ( + <> + {" — "} + + {SEVERITY_LABELS[m.severity] ?? m.severity} + + + )} +
    + {m.carried_over_from_id && ( + + перенесено + + )} +
    + {m.description && ( +
    + {m.description} +
    + )} +
    +
  • + ))} +
+
+ )} + + {editable && ( + + )}
); } diff --git a/mechanic-pwa/frontend/src/pages/InspectionReview.tsx b/mechanic-pwa/frontend/src/pages/InspectionReview.tsx index 4029cfd..af91fd9 100644 --- a/mechanic-pwa/frontend/src/pages/InspectionReview.tsx +++ b/mechanic-pwa/frontend/src/pages/InspectionReview.tsx @@ -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 = { + 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
Загружаю…
; + if (isError || !ins) + return
Осмотр не найден.
; + return ( -
-

Осмотр #{id}

+
+ + +

Осмотр #{ins.id}

+ + +
+ Тип: + {ins.type} +
+
+ Статус: + {STATUS_LABELS[ins.status] ?? ins.status} +
+
+ Начат: + {new Date(ins.started_at).toLocaleString("ru-RU")} +
+ {ins.finished_at && ( +
+ Завершён: + {new Date(ins.finished_at).toLocaleString("ru-RU")} +
+ )} + {ins.notes && ( +
+ Заметки: + {ins.notes} +
+ )} +
+ +
+

+ Фото ({ins.photos.length}) +

+ {ins.photos.length === 0 ? ( +
Нет фото.
+ ) : ( +
+ {ins.photos.map((p: PhotoSummary) => ( + + +
{p.side}
+
+ ))} +
+ )} +
+ +
+

+ Метки ({ins.markers.length}) +

+ {ins.markers.length === 0 ? ( +
Нет меток.
+ ) : ( +
    + {ins.markers.map((m: MarkerSummary) => ( +
  • + +
    +
    + {m.side ?? "—"} + {" — "} + {m.damage_type ?? "—"} + {m.severity && ( + + {" — "} + {m.severity} + + )} +
    +
    + {m.resolved && ( + + устранено + + )} + {m.carried_over_from_id && ( + + перенесено + + )} +
    +
    + {m.description && ( +
    + {m.description} +
    + )} +
    +
  • + ))} +
+ )} +
); }