181 lines
6.2 KiB
TypeScript
181 lines
6.2 KiB
TypeScript
import { CalendarCheck2, ImageIcon, MapPin, Smartphone } from 'lucide-react';
|
|
|
|
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
|
|
import { cn } from '@/lib/utils';
|
|
import type { DetectionResultItem } from '@/types';
|
|
import { formatDate } from '@/utils/date';
|
|
|
|
import { RepairProofGallery } from '../../components/repair/RepairProofGallery';
|
|
import { formatVideoTimestamp, type IssueReviewStatus } from './reviewTypes';
|
|
|
|
const REVIEW_STATUS_LABEL: Record<IssueReviewStatus, string> = {
|
|
not_submitted: 'Awaiting Repair',
|
|
pending: 'Under Review',
|
|
approved: 'Approved',
|
|
rejected: 'Rejected',
|
|
};
|
|
|
|
const REVIEW_STATUS_CLASS: Record<IssueReviewStatus, string> = {
|
|
not_submitted: 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-300',
|
|
pending: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
|
approved: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
|
rejected: 'bg-destructive/10 text-destructive',
|
|
};
|
|
|
|
function MetadataItem({
|
|
label,
|
|
value,
|
|
}: {
|
|
label: string;
|
|
value: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="min-w-0 space-y-1 px-4 first:pl-0 last:pr-0">
|
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
<div className="truncate text-sm font-semibold text-foreground">
|
|
{value}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface IssueEvidencePanelProps {
|
|
detection: DetectionResultItem;
|
|
issueNumber: number;
|
|
videoWidth: number;
|
|
videoHeight: number;
|
|
reviewStatus: IssueReviewStatus;
|
|
}
|
|
|
|
export function IssueEvidencePanel({
|
|
detection,
|
|
issueNumber,
|
|
videoWidth,
|
|
videoHeight,
|
|
reviewStatus,
|
|
}: IssueEvidencePanelProps) {
|
|
const repairProof = detection.repair_proof;
|
|
const displayName = detection.detection.display_name;
|
|
const confidence = Math.round(detection.detection.confidence * 100);
|
|
|
|
return (
|
|
<div className="min-w-0 space-y-4">
|
|
<Card>
|
|
<CardHeader className="border-b">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<CardTitle className="text-base">
|
|
{displayName} #{issueNumber}
|
|
</CardTitle>
|
|
<Badge
|
|
variant="secondary"
|
|
className={cn(REVIEW_STATUS_CLASS[reviewStatus])}
|
|
>
|
|
{REVIEW_STATUS_LABEL[reviewStatus]}
|
|
</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-2 divide-x sm:grid-cols-4">
|
|
<MetadataItem
|
|
label="Video Timestamp"
|
|
value={formatVideoTimestamp(detection.frame.timestamp_seconds)}
|
|
/>
|
|
<MetadataItem label="Frame" value={detection.frame.number} />
|
|
<MetadataItem label="Confidence" value={`${confidence}%`} />
|
|
<MetadataItem label="Issue ID" value={detection.detection.id} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="grid items-start gap-4 2xl:grid-cols-2">
|
|
<section className="min-w-0 space-y-2">
|
|
<p className="flex items-center gap-2 text-sm font-semibold">
|
|
<ImageIcon className="size-4 text-primary" />
|
|
Detection Image (Before)
|
|
</p>
|
|
<AnnotatedDetectionImage
|
|
detection={detection}
|
|
videoWidth={videoWidth}
|
|
videoHeight={videoHeight}
|
|
aspectRatio="16 / 9"
|
|
enableLightbox
|
|
/>
|
|
</section>
|
|
|
|
<section className="min-w-0 space-y-2">
|
|
<p className="flex items-center gap-2 text-sm font-semibold">
|
|
<MapPin className="size-4 text-primary" />
|
|
Detection Location
|
|
</p>
|
|
<DetectionLocationMap
|
|
latitude={detection.location.latitude}
|
|
longitude={detection.location.longitude}
|
|
label={`${displayName} #${issueNumber} - Frame ${detection.frame.number}`}
|
|
title=""
|
|
variant="plain"
|
|
aspectRatio="16 / 9"
|
|
showCoordinates={false}
|
|
/>
|
|
</section>
|
|
</div>
|
|
|
|
<section className="space-y-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<p className="flex items-center gap-2 text-sm font-semibold">
|
|
<CalendarCheck2 className="size-4 text-primary" />
|
|
Repair Proof (After Repair)
|
|
</p>
|
|
{repairProof?.submitted ? (
|
|
<Badge className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
|
<Smartphone />
|
|
Uploaded from App
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
<div className="space-y-5">
|
|
{!repairProof?.submitted ? (
|
|
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center">
|
|
<p className="text-sm font-medium">
|
|
Repair proof is not submitted yet.
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
This issue cannot be reviewed until repair evidence is
|
|
available.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{repairProof.submitted_at ? (
|
|
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-4 py-3">
|
|
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-300">
|
|
Uploaded from the field app on{' '}
|
|
{formatDate(repairProof.submitted_at)}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
<RepairProofGallery
|
|
imageUrls={repairProof.proof_image_urls}
|
|
maxVisibleImages={4}
|
|
/>
|
|
|
|
<div className="space-y-1.5 rounded-lg border bg-card p-4 shadow-xs">
|
|
<p className="text-xs font-semibold text-foreground">
|
|
Repair Notes
|
|
</p>
|
|
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
|
|
{repairProof.notes?.trim() ||
|
|
'No repair notes were provided.'}
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|