inspection-review: TT-фото повреждений, счётчик и lightbox для всех маркеров

1. Photo counter теперь учитывает уникальные tt_image_id из markers, не только
   ins.photos + photo_id. На осмотрах, где почти все маркеры пришли из Element
   carry-over, счётчик показывал 1 вместо 15+ — теперь корректно.

2. В галерее «Фото» добавлена секция карточек для TT-image carry-over (label
   «из Element»). Картинки тянутся через ttImageUrl(hash), кликабельны.

3. Lightbox state поменялся с `number | null` на дискриминированный union
   { kind: 'photo', id } | { kind: 'tt', ttId }. Полноразмерное превью
   выбирает между AuthImg (наши MinIO-фото) и обычным <img> (Element).

4. Превью маркеров в секции «Метки» теперь кликабельны (раньше только грид
   с основными фото открывал lightbox). Работает и для photo_id, и для
   tt_image_id маркеров.

Backend-зависимость: MarkerOut.tt_image_id (Pydantic) и
MechanicDamageMarker.tt_image_id (ORM) добавлены в PremiumCRM коммитом
d405e17 на crm.pptaxi.ru — без них фронт получает tt_image_id=undefined.
This commit is contained in:
2026-05-20 13:11:04 +10:00
parent 7e2cf0c9c8
commit b4e0dd6d65
2 changed files with 123 additions and 5 deletions
@@ -66,6 +66,8 @@ export interface InspectionDetail {
photos: PhotoSummary[]; photos: PhotoSummary[];
markers: MarkerSummary[]; markers: MarkerSummary[];
mileage?: number | null; mileage?: number | null;
/** ФИО механика, выполнившего осмотр (lookup users.full_name по performed_by). */
inspector_name?: string | null;
} }
export async function createInspection( export async function createInspection(
@@ -1,3 +1,4 @@
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -114,6 +115,12 @@ export default function InspectionReview() {
staleTime: Infinity, staleTime: Infinity,
}); });
// Lightbox: либо ID нашего фото в MinIO (через AuthImg), либо vendor TT
// image hash (через ttImageUrl).
const [preview, setPreview] = useState<
{ kind: "photo"; id: number } | { kind: "tt"; ttId: string } | null
>(null);
if (isLoading) return <div className="p-8 text-muted-foreground">Загружаю</div>; if (isLoading) return <div className="p-8 text-muted-foreground">Загружаю</div>;
if (isError || !ins) if (isError || !ins)
return <div className="p-8 text-destructive">Осмотр не найден.</div>; return <div className="p-8 text-destructive">Осмотр не найден.</div>;
@@ -122,6 +129,25 @@ export default function InspectionReview() {
const photosById = new Map<number, typeof ins.photos[0]>(); const photosById = new Map<number, typeof ins.photos[0]>();
for (const p of ins.photos) photosById.set(p.id, p); for (const p of ins.photos) photosById.set(p.id, p);
// Все уникальные фото осмотра: основные + photo_id из маркеров (наши локальные
// фото повреждений) + tt_image_id из маркеров (carry-over из Element). Без
// tt_image_id counter недоучитывал импортированные дефекты — видно на
// осмотрах, где почти все маркеры пришли из вендора.
const markerPhotoIds = ins.markers
.map((m: MarkerSummary) => m.photo_id)
.filter((id): id is number => id != null);
const markerTtIds = ins.markers
.map((m: MarkerSummary) => m.tt_image_id)
.filter((id): id is string => !!id);
const uniquePhotoIds = Array.from(
new Set([...ins.photos.map((p: PhotoSummary) => p.id), ...markerPhotoIds])
);
const uniqueTtIds = Array.from(new Set(markerTtIds));
const totalPhotoCount = uniquePhotoIds.length + uniqueTtIds.length;
// Дополнительные фото-карточки в галерее: photo_id маркеров, которых нет в
// основном списке (`ins.photos` — это side-photos осмотра).
const extraMarkerPhotos = markerPhotoIds.filter((id) => !photosById.has(id));
return ( return (
<div className="min-h-screen bg-background p-4 space-y-4"> <div className="min-h-screen bg-background p-4 space-y-4">
<button <button
@@ -142,6 +168,12 @@ export default function InspectionReview() {
<span className="font-semibold">Статус: </span> <span className="font-semibold">Статус: </span>
{STATUS_LABELS[ins.status] ?? ins.status} {STATUS_LABELS[ins.status] ?? ins.status}
</div> </div>
{ins.inspector_name && (
<div>
<span className="font-semibold">Механик: </span>
{ins.inspector_name}
</div>
)}
<div> <div>
<span className="font-semibold">Начат: </span> <span className="font-semibold">Начат: </span>
{new Date(ins.started_at).toLocaleString("ru-RU")} {new Date(ins.started_at).toLocaleString("ru-RU")}
@@ -179,14 +211,18 @@ export default function InspectionReview() {
<div> <div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2"> <h2 className="text-sm font-semibold text-muted-foreground mb-2">
Фото ({ins.photos.length}) Фото ({totalPhotoCount})
</h2> </h2>
{ins.photos.length === 0 ? ( {totalPhotoCount === 0 ? (
<div className="text-sm text-muted-foreground">Нет фото.</div> <div className="text-sm text-muted-foreground">Нет фото.</div>
) : ( ) : (
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{ins.photos.map((p: PhotoSummary) => ( {ins.photos.map((p: PhotoSummary) => (
<Card key={p.id} className="overflow-hidden"> <Card
key={p.id}
className="overflow-hidden cursor-pointer"
onClick={() => setPreview({ kind: "photo", id: p.id })}
>
<AuthImg <AuthImg
src={`photos/${p.id}/thumb`} src={`photos/${p.id}/thumb`}
alt={p.side} alt={p.side}
@@ -195,6 +231,37 @@ export default function InspectionReview() {
<div className="text-xs text-muted-foreground p-2">{p.side}</div> <div className="text-xs text-muted-foreground p-2">{p.side}</div>
</Card> </Card>
))} ))}
{/* Фото-маркеров, которых нет в основном списке (наши локальные) */}
{extraMarkerPhotos.map((id) => (
<Card
key={`m-${id}`}
className="overflow-hidden cursor-pointer"
onClick={() => setPreview({ kind: "photo", id })}
>
<AuthImg
src={`photos/${id}/thumb`}
alt="повреждение"
className="w-full aspect-[4/3] object-cover bg-muted"
/>
<div className="text-xs text-muted-foreground p-2">повреждение</div>
</Card>
))}
{/* TT-фото carry-over дефектов из Element */}
{uniqueTtIds.map((ttId) => (
<Card
key={`tt-${ttId}`}
className="overflow-hidden cursor-pointer"
onClick={() => setPreview({ kind: "tt", ttId })}
>
<img
src={ttImageUrl(ttId)}
alt="повреждение (Element)"
loading="lazy"
className="w-full aspect-[4/3] object-cover bg-muted"
/>
<div className="text-xs text-muted-foreground p-2">из Element</div>
</Card>
))}
</div> </div>
)} )}
</div> </div>
@@ -249,7 +316,12 @@ export default function InspectionReview() {
</div> </div>
)} )}
{m.photo_id ? ( {m.photo_id ? (
<div className="mt-2 max-w-[8rem]"> <div
className="mt-2 max-w-[8rem] cursor-pointer"
onClick={() =>
setPreview({ kind: "photo", id: m.photo_id as number })
}
>
<AuthImg <AuthImg
src={ src={
photosById.get(m.photo_id)?.annotated_key photosById.get(m.photo_id)?.annotated_key
@@ -261,7 +333,12 @@ export default function InspectionReview() {
/> />
</div> </div>
) : m.tt_image_id ? ( ) : m.tt_image_id ? (
<div className="mt-2 max-w-[8rem]"> <div
className="mt-2 max-w-[8rem] cursor-pointer"
onClick={() =>
setPreview({ kind: "tt", ttId: m.tt_image_id as string })
}
>
<img <img
src={ttImageUrl(m.tt_image_id)} src={ttImageUrl(m.tt_image_id)}
alt="Фото из Element Mechanic" alt="Фото из Element Mechanic"
@@ -295,6 +372,45 @@ export default function InspectionReview() {
</button> </button>
</div> </div>
)} )}
{/* Lightbox preview — click photo to enlarge, click background to close.
Соответствует UX в TtStateDetail (импортированные из Element осмотры). */}
{preview != null && (
<div
className="fixed inset-0 z-50 bg-black/90 flex flex-col"
onClick={() => setPreview(null)}
>
<div className="flex items-center justify-between p-3">
<button
onClick={(e) => {
e.stopPropagation();
setPreview(null);
}}
className="text-white text-2xl px-3"
>
</button>
</div>
<div
className="flex-1 flex items-center justify-center p-4"
onClick={(e) => e.stopPropagation()}
>
{preview.kind === "photo" ? (
<AuthImg
src={`photos/${preview.id}`}
alt="Просмотр"
className="max-w-full max-h-full object-contain"
/>
) : (
<img
src={ttImageUrl(preview.ttId)}
alt="Просмотр (Element)"
className="max-w-full max-h-full object-contain"
/>
)}
</div>
</div>
)}
</div> </div>
); );
} }