fix: Hotspots now update correctly when switching subgroups
## Changes ### Backend (vehicles.service.ts) - Download ALL subgroup schema images on-demand (not just first) - Store per-subgroup schemaWidth, schemaHeight, and hotspots in DB - Return per-subgroup hotspot data in API response ### Frontend ([categoryId]/page.tsx) - Add useMemo hooks for selectedSubGroupData, schemaImageUrl, hotspots - Hotspots now properly update when subgroup changes - Schema image changes correctly with subgroup selection - Reset imageSize state on subgroup change for hotspot recalculation ### PL24 Integration - Add pl24-db.service.ts for database operations - Improve schema image downloading and caching ## Bug Fixed When switching between subgroups (tabs) on the category parts page, the schema image was changing but hotspots remained fixed on the first subgroup's values. Now both image and hotspots update correctly. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
@@ -45,6 +45,8 @@ interface Part {
|
||||
// Quantity (Unit column in PL24)
|
||||
quantity?: number | null;
|
||||
positionCode: string | null;
|
||||
// Hotspot ID for schema image interaction (e.g., "1A", "2")
|
||||
hotspotId?: string | null;
|
||||
// Model codes / PR codes for compatibility (e.g., "PR:1PD+F...FM4")
|
||||
modelCodes?: string | null;
|
||||
imageUrl: string | null;
|
||||
@@ -56,12 +58,28 @@ interface Part {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface HotspotArea {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface Hotspot {
|
||||
key: string;
|
||||
areas: HotspotArea[];
|
||||
}
|
||||
|
||||
interface SubGroup {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
schemaImageUrl: string | null;
|
||||
partCount: number;
|
||||
// Per-subgroup schema image dimensions and hotspots
|
||||
schemaWidth?: number | null;
|
||||
schemaHeight?: number | null;
|
||||
hotspots?: Hotspot[] | null;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
@@ -97,6 +115,13 @@ interface CategoryPartsData {
|
||||
subGroups?: SubGroup[];
|
||||
parts: Part[];
|
||||
totalParts: number;
|
||||
// Hotspot coordinates for schema image interaction
|
||||
hotspots?: Hotspot[] | null;
|
||||
// Original schema image dimensions for scaling hotspots
|
||||
schemaImageDimensions?: {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function CategoryPartsPage() {
|
||||
@@ -114,21 +139,26 @@ export default function CategoryPartsPage() {
|
||||
const [copiedOem, setCopiedOem] = useState<string | null>(null);
|
||||
const [selectedPosition, setSelectedPosition] = useState<string | null>(null);
|
||||
const [hoveredPart, setHoveredPart] = useState<string | null>(null);
|
||||
const [selectedHotspot, setSelectedHotspot] = useState<string | null>(null);
|
||||
const [selectedSubGroup, setSelectedSubGroup] = useState<string | null>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showVehicleInfo, setShowVehicleInfo] = useState(false);
|
||||
const partRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const diagramRef = useRef<HTMLDivElement>(null);
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchCategoryParts() {
|
||||
try {
|
||||
const response = await apiClient.get(`/vehicles/${vin}/categories/${categoryId}/parts`);
|
||||
setData(response.data.data);
|
||||
const responseData = response.data.data;
|
||||
|
||||
setData(responseData);
|
||||
// Select first subgroup by default if available
|
||||
if (response.data.data?.subGroups?.length > 0) {
|
||||
setSelectedSubGroup(response.data.data.subGroups[0].id);
|
||||
if (responseData?.subGroups?.length > 0) {
|
||||
setSelectedSubGroup(responseData.subGroups[0].id);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || 'Parcalar yuklenemedi');
|
||||
@@ -142,6 +172,11 @@ export default function CategoryPartsPage() {
|
||||
}
|
||||
}, [vin, categoryId]);
|
||||
|
||||
// Reset image size when subgroup changes (so hotspots recalculate)
|
||||
useEffect(() => {
|
||||
setImageSize(null);
|
||||
}, [selectedSubGroup]);
|
||||
|
||||
const copyOemCode = useCallback(async (oemCode: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(oemCode);
|
||||
@@ -156,9 +191,21 @@ export default function CategoryPartsPage() {
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
// Scroll to part when position is clicked on diagram
|
||||
// Scroll to part when hotspot is clicked on diagram
|
||||
const handleHotspotClick = useCallback((hotspotKey: string) => {
|
||||
setSelectedHotspot(hotspotKey);
|
||||
setSelectedPosition(hotspotKey);
|
||||
// Find first part with this hotspotId
|
||||
const partRef = partRefs.current.get(hotspotKey);
|
||||
if (partRef) {
|
||||
partRef.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Scroll to part when position is clicked on diagram (legacy)
|
||||
const handlePositionClick = useCallback((positionCode: string) => {
|
||||
setSelectedPosition(positionCode);
|
||||
setSelectedHotspot(positionCode);
|
||||
const partRef = partRefs.current.get(positionCode);
|
||||
if (partRef) {
|
||||
partRef.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
@@ -195,16 +242,36 @@ export default function CategoryPartsPage() {
|
||||
);
|
||||
}) || [];
|
||||
|
||||
// Get current schema image (from selected subgroup or category)
|
||||
const getCurrentSchemaImage = () => {
|
||||
if (selectedSubGroup && data?.subGroups) {
|
||||
const sg = data.subGroups.find(s => s.id === selectedSubGroup);
|
||||
if (sg?.schemaImageUrl) return sg.schemaImageUrl;
|
||||
}
|
||||
return data?.category?.schemaImageUrl;
|
||||
};
|
||||
// Get selected subgroup data - memoized to ensure proper updates
|
||||
const selectedSubGroupData = useMemo(() => {
|
||||
if (!selectedSubGroup || !data?.subGroups) return null;
|
||||
return data.subGroups.find(s => s.id === selectedSubGroup) || null;
|
||||
}, [selectedSubGroup, data?.subGroups]);
|
||||
|
||||
// Memoize schema image URL
|
||||
const schemaImageUrl = useMemo(() => {
|
||||
return selectedSubGroupData?.schemaImageUrl || data?.category?.schemaImageUrl || null;
|
||||
}, [selectedSubGroupData, data?.category?.schemaImageUrl]);
|
||||
|
||||
// Memoize hotspots - CRITICAL: must update when subgroup changes
|
||||
const hotspots = useMemo((): Hotspot[] => {
|
||||
if (selectedSubGroupData?.hotspots && selectedSubGroupData.hotspots.length > 0) {
|
||||
return selectedSubGroupData.hotspots;
|
||||
}
|
||||
return data?.hotspots || [];
|
||||
}, [selectedSubGroupData, data?.hotspots]);
|
||||
|
||||
// Memoize dimensions
|
||||
const originalDimensions = useMemo(() => {
|
||||
if (selectedSubGroupData?.schemaWidth && selectedSubGroupData?.schemaHeight) {
|
||||
return {
|
||||
width: selectedSubGroupData.schemaWidth,
|
||||
height: selectedSubGroupData.schemaHeight,
|
||||
};
|
||||
}
|
||||
return data?.schemaImageDimensions || null;
|
||||
}, [selectedSubGroupData, data?.schemaImageDimensions]);
|
||||
|
||||
const schemaImageUrl = getCurrentSchemaImage();
|
||||
const positionCodes = getPositionCodes();
|
||||
|
||||
if (isLoading) {
|
||||
@@ -492,36 +559,89 @@ export default function CategoryPartsPage() {
|
||||
className="relative min-w-full min-h-full flex items-center justify-center p-4"
|
||||
style={{ transform: `scale(${zoom})`, transformOrigin: 'center' }}
|
||||
>
|
||||
<Image
|
||||
src={schemaImageUrl}
|
||||
alt={category.nameTr}
|
||||
width={800}
|
||||
height={600}
|
||||
className="max-w-full h-auto object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
{/* Image with hotspot overlay */}
|
||||
<div ref={imageContainerRef} className="relative">
|
||||
<Image
|
||||
key={`schema-${selectedSubGroup || 'default'}-${schemaImageUrl}`}
|
||||
src={schemaImageUrl}
|
||||
alt={category.nameTr}
|
||||
width={originalDimensions?.width || 800}
|
||||
height={originalDimensions?.height || 600}
|
||||
className="max-w-full h-auto object-contain"
|
||||
unoptimized
|
||||
onLoad={(e) => {
|
||||
const img = e.currentTarget;
|
||||
setImageSize({ width: img.clientWidth, height: img.clientHeight });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Hotspot overlays - clickable areas on schema */}
|
||||
{imageSize && originalDimensions?.width && originalDimensions?.height && hotspots.length > 0 && (
|
||||
<div key={`hotspots-${selectedSubGroup || 'default'}`} className="absolute inset-0 pointer-events-none">
|
||||
{hotspots.map((hotspot) =>
|
||||
hotspot.areas.map((area, areaIndex) => {
|
||||
// Scale coordinates from original image to displayed size
|
||||
const scaleX = imageSize.width / originalDimensions.width!;
|
||||
const scaleY = imageSize.height / originalDimensions.height!;
|
||||
const scaledLeft = area.left * scaleX;
|
||||
const scaledTop = area.top * scaleY;
|
||||
const scaledWidth = area.width * scaleX;
|
||||
const scaledHeight = area.height * scaleY;
|
||||
|
||||
const isSelected = selectedHotspot === hotspot.key;
|
||||
const isHovered = hoveredPart === hotspot.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${hotspot.key}-${areaIndex}`}
|
||||
className={`absolute pointer-events-auto transition-all cursor-pointer rounded border-2 flex items-center justify-center text-xs font-bold ${
|
||||
isSelected
|
||||
? 'bg-purple-500/50 border-purple-600 text-white shadow-lg'
|
||||
: isHovered
|
||||
? 'bg-purple-300/50 border-purple-400 text-purple-900'
|
||||
: 'bg-yellow-300/30 border-yellow-500/50 hover:bg-purple-300/50 hover:border-purple-400'
|
||||
}`}
|
||||
style={{
|
||||
left: `${scaledLeft}px`,
|
||||
top: `${scaledTop}px`,
|
||||
width: `${Math.max(scaledWidth, 20)}px`,
|
||||
height: `${Math.max(scaledHeight, 16)}px`,
|
||||
}}
|
||||
onClick={() => handleHotspotClick(hotspot.key)}
|
||||
title={`Pozisyon ${hotspot.key}`}
|
||||
>
|
||||
{scaledWidth >= 20 && hotspot.key}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Position Markers (overlay on diagram) */}
|
||||
<div className="absolute bottom-4 left-4 right-4 flex flex-wrap gap-1.5 bg-white/90 dark:bg-gray-900/90 p-2 rounded-lg backdrop-blur-sm max-h-32 overflow-y-auto">
|
||||
{positionCodes.map((pos) => (
|
||||
<Button
|
||||
key={pos}
|
||||
variant={selectedPosition === pos ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={`h-7 w-7 p-0 text-xs font-bold rounded-full ${
|
||||
selectedPosition === pos
|
||||
? 'gradient-bg'
|
||||
: hoveredPart === pos
|
||||
? 'bg-purple-100 border-purple-500 dark:bg-purple-900/50'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => handlePositionClick(pos)}
|
||||
>
|
||||
{pos}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{/* Position Markers (fallback when no hotspots) */}
|
||||
{hotspots.length === 0 && positionCodes.length > 0 && (
|
||||
<div className="absolute bottom-4 left-4 right-4 flex flex-wrap gap-1.5 bg-white/90 dark:bg-gray-900/90 p-2 rounded-lg backdrop-blur-sm max-h-32 overflow-y-auto">
|
||||
{positionCodes.map((pos) => (
|
||||
<Button
|
||||
key={pos}
|
||||
variant={selectedPosition === pos ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={`h-7 w-7 p-0 text-xs font-bold rounded-full ${
|
||||
selectedPosition === pos
|
||||
? 'gradient-bg'
|
||||
: hoveredPart === pos
|
||||
? 'bg-purple-100 border-purple-500 dark:bg-purple-900/50'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => handlePositionClick(pos)}
|
||||
>
|
||||
{pos}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -538,22 +658,32 @@ export default function CategoryPartsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredParts.map((part) => (
|
||||
filteredParts.map((part) => {
|
||||
// Use hotspotId for schema interaction, fallback to positionCode
|
||||
const partKey = part.hotspotId || part.positionCode;
|
||||
const isSelected = partKey && (selectedHotspot === partKey || selectedPosition === partKey);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={part.id}
|
||||
ref={(el) => {
|
||||
if (el && part.positionCode) {
|
||||
partRefs.current.set(part.positionCode, el);
|
||||
if (el && partKey) {
|
||||
partRefs.current.set(partKey, el);
|
||||
}
|
||||
}}
|
||||
className={`border-2 transition-all cursor-pointer ${
|
||||
selectedPosition === part.positionCode
|
||||
isSelected
|
||||
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20 shadow-lg'
|
||||
: 'border-transparent hover:border-purple-300 dark:hover:border-purple-700 hover:shadow-md'
|
||||
}`}
|
||||
onMouseEnter={() => setHoveredPart(part.positionCode)}
|
||||
onMouseEnter={() => setHoveredPart(partKey || null)}
|
||||
onMouseLeave={() => setHoveredPart(null)}
|
||||
onClick={() => part.positionCode && setSelectedPosition(part.positionCode)}
|
||||
onClick={() => {
|
||||
if (partKey) {
|
||||
setSelectedHotspot(partKey);
|
||||
setSelectedPosition(partKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -626,7 +756,8 @@ export default function CategoryPartsPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user