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:
Semih
2026-01-22 18:56:24 +01:00
parent 2358b38b4d
commit 242ef0d053
73 changed files with 1074 additions and 127 deletions

View File

@@ -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>