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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -5,4 +5,5 @@
export * from './pl24.types';
export * from './pl24-auth.service';
export * from './pl24.service';
export * from './pl24-db.service';
export * from './pl24.module';

View File

@@ -0,0 +1,600 @@
/**
* PL24 Database Service
*
* Handles all database operations for PL24 data with proper deduplication.
* Key principles:
* - VIN is unique, but vehicle models are shared across VINs
* - Parts are catalog-level (shared across vehicles)
* - Groups are catalog-level (shared across vehicles)
* - Hash comparison for change detection
*/
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import * as crypto from 'crypto';
import {
PL24_SERVICE_CATALOGS,
PL24DecodedVehicle,
PL24DecodedCategory,
PL24Part as PL24ApiPart,
} from './pl24.types';
// Utility to compute MD5 hash
function computeHash(data: unknown): string {
const str = JSON.stringify(data, Object.keys(data as object).sort());
return crypto.createHash('md5').update(str).digest('hex');
}
// Utility to create unique key
function createUniqueKey(...parts: string[]): string {
return crypto.createHash('md5').update(parts.join('|')).digest('hex');
}
// Clean part number (remove spaces, dashes)
function cleanPartNumber(partNo: string): string {
return partNo.replace(/[\s\-\.]/g, '').toUpperCase();
}
@Injectable()
export class PL24DbService {
private readonly logger = new Logger(PL24DbService.name);
constructor(private prisma: PrismaService) {}
/**
* Get or create a PL24 catalog
*/
async getOrCreateCatalog(serviceName: string): Promise<string> {
const catalogBase = PL24_SERVICE_CATALOGS[serviceName] || '/p5vwag';
// Extract brand from service name (e.g., "vw_parts" -> "VW")
const brandMatch = serviceName.match(/^(\w+)_/);
const brand = brandMatch ? brandMatch[1].toUpperCase() : serviceName.toUpperCase();
// Map service names to readable names
const nameMap: Record<string, string> = {
vw_parts: 'Volkswagen',
audi_parts: 'Audi',
seat_parts: 'SEAT',
skoda_parts: 'Skoda',
vw_commercial: 'Volkswagen Commercial',
porsche_parts: 'Porsche',
bentley_parts: 'Bentley',
lamborghini_parts: 'Lamborghini',
renault_parts: 'Renault',
dacia_parts: 'Dacia',
bmw_parts: 'BMW',
mini_parts: 'MINI',
mercedes_parts: 'Mercedes-Benz',
smart_parts: 'smart',
};
const name = nameMap[serviceName] || brand;
let catalog = await this.prisma.pL24Catalog.findUnique({
where: { serviceName },
});
if (!catalog) {
catalog = await this.prisma.pL24Catalog.create({
data: {
serviceName,
name,
brand,
catalogBase,
},
});
this.logger.log(`Created PL24 catalog: ${serviceName}`);
}
return catalog.id;
}
/**
* Find existing vehicle by unique characteristics
* Returns vehicle if found, null otherwise
*/
async findExistingVehicle(
catalogId: string,
vehicleId: string,
): Promise<{ id: string; dataHash: string | null } | null> {
const uniqueKey = createUniqueKey(catalogId, vehicleId);
const vehicle = await this.prisma.pL24Vehicle.findUnique({
where: { uniqueKey },
select: { id: true, dataHash: true },
});
return vehicle;
}
/**
* Find vehicle by VIN
*/
async findVehicleByVin(vin: string): Promise<{
vehicle: {
id: string;
catalogId: string;
name: string;
model: string;
series: string | null;
bodyType: string | null;
engineCode: string | null;
engineType: string | null;
transmission: string | null;
driveType: string | null;
mainGroupsPath: string | null;
scrapeComplete: boolean;
catalog: { serviceName: string };
};
vinInfo: {
productionDate: string | null;
colorCode: string | null;
equipment: string | null;
};
} | null> {
const vinLink = await this.prisma.pL24VehicleVin.findUnique({
where: { vin: vin.toUpperCase() },
include: {
vehicle: {
include: {
catalog: true,
},
},
},
});
if (!vinLink) return null;
return {
vehicle: vinLink.vehicle,
vinInfo: {
productionDate: vinLink.productionDate,
colorCode: vinLink.colorCode,
equipment: vinLink.equipment,
},
};
}
/**
* Save vehicle from PL24 response with deduplication
*/
async saveVehicle(
catalogId: string,
decoded: PL24DecodedVehicle,
vin: string,
): Promise<string> {
const vehicleId = decoded.catalogInfo?.vehicleId || '';
const uniqueKey = createUniqueKey(catalogId, vehicleId);
// Compute hash of vehicle data for change detection
const vehicleData = {
name: decoded.model,
model: decoded.model,
series: decoded.series,
bodyType: decoded.bodyType,
engineCode: decoded.engineCode,
engineType: decoded.engineType,
engineVolume: decoded.engineVolume,
transmission: decoded.transmission,
driveType: decoded.driveType,
};
const dataHash = computeHash(vehicleData);
// Check if vehicle exists
const existing = await this.findExistingVehicle(catalogId, vehicleId);
let pl24VehicleId: string;
if (existing) {
// Vehicle exists, check if data changed
if (existing.dataHash !== dataHash) {
// Update vehicle data
await this.prisma.pL24Vehicle.update({
where: { id: existing.id },
data: {
...vehicleData,
catalogPath: decoded.catalogInfo?.catalogPath || '',
mainGroupsPath: decoded.catalogInfo?.mainGroupsPath,
rawSegments: decoded.raw?.segments as any,
dataHash,
},
});
this.logger.log(`Updated PL24 vehicle: ${decoded.model} (data changed)`);
}
pl24VehicleId = existing.id;
} else {
// Create new vehicle
const newVehicle = await this.prisma.pL24Vehicle.create({
data: {
catalogId,
vehicleId,
name: decoded.model || 'Unknown',
model: decoded.model || 'Unknown',
series: decoded.series,
bodyType: decoded.bodyType,
engineCode: decoded.engineCode,
engineType: decoded.engineType,
engineVolume: decoded.engineVolume,
transmission: decoded.transmission,
driveType: decoded.driveType,
catalogPath: decoded.catalogInfo?.catalogPath || '',
mainGroupsPath: decoded.catalogInfo?.mainGroupsPath,
rawSegments: decoded.raw?.segments as any,
uniqueKey,
dataHash,
},
});
pl24VehicleId = newVehicle.id;
this.logger.log(`Created PL24 vehicle: ${decoded.model}`);
}
// Link VIN to vehicle (VIN is always unique)
await this.linkVinToVehicle(vin, pl24VehicleId, decoded);
return pl24VehicleId;
}
/**
* Link VIN to vehicle
*/
private async linkVinToVehicle(
vin: string,
vehicleId: string,
decoded: PL24DecodedVehicle,
): Promise<void> {
const normalizedVin = vin.toUpperCase();
// Extract VIN-specific info from raw response
const rawSegments = decoded.raw?.segments as Record<string, any> | undefined;
const vinfoRecords = rawSegments?.vinfoBasic?.records || [];
const vehicleData: Record<string, string> = {};
for (const record of vinfoRecords) {
if (record.values) {
const key = record.values.description?.toLowerCase().replace(/[\s\/]+/g, '_') || '';
vehicleData[key] = record.values.value?.replace(/\r?\n/g, ' ').trim() || '';
}
}
await this.prisma.pL24VehicleVin.upsert({
where: { vin: normalizedVin },
update: {
vehicleId,
productionDate: vehicleData['date_of_production'] || null,
colorCode: vehicleData['exterior_color___paint_code'] || vehicleData['roof_color'] || null,
interiorCode: vehicleData['interior'] || null,
transmissionCode: vehicleData['transmission_code'] || null,
equipment: vehicleData['equipment'] || null,
salesType: vehicleData['sales_type'] || null,
},
create: {
vin: normalizedVin,
vehicleId,
productionDate: vehicleData['date_of_production'] || null,
colorCode: vehicleData['exterior_color___paint_code'] || vehicleData['roof_color'] || null,
interiorCode: vehicleData['interior'] || null,
transmissionCode: vehicleData['transmission_code'] || null,
equipment: vehicleData['equipment'] || null,
salesType: vehicleData['sales_type'] || null,
},
});
this.logger.log(`Linked VIN ${normalizedVin} to vehicle ${vehicleId}`);
}
/**
* Save main groups (categories) for a vehicle
*/
async saveVehicleGroups(
catalogId: string,
vehicleId: string,
categories: PL24DecodedCategory[],
): Promise<void> {
for (const cat of categories) {
// Get or create the group at catalog level
const groupId = await this.getOrCreateGroup(catalogId, cat);
// Link vehicle to group with vehicle-specific data
const uniqueKey = createUniqueKey(vehicleId, groupId);
await this.prisma.pL24VehicleGroup.upsert({
where: { vehicleId_groupId: { vehicleId, groupId } },
update: {
linkPath: cat.linkPath,
linkWid: cat.linkWid,
},
create: {
vehicleId,
groupId,
linkPath: cat.linkPath,
linkWid: cat.linkWid,
},
});
}
this.logger.log(`Saved ${categories.length} vehicle-group links`);
}
/**
* Get or create a part group (category) at catalog level
*/
private async getOrCreateGroup(
catalogId: string,
category: PL24DecodedCategory,
): Promise<string> {
const uniqueKey = createUniqueKey(catalogId, category.code);
let group = await this.prisma.pL24PartGroup.findUnique({
where: { uniqueKey },
});
if (!group) {
group = await this.prisma.pL24PartGroup.create({
data: {
catalogId,
groupCode: category.code,
name: category.nameEn,
nameTr: category.nameTr,
description: category.description,
iconUrl: category.iconUrl,
uniqueKey,
hasParts: true,
},
});
}
return group.id;
}
/**
* Get vehicle groups (categories) from database
*/
async getVehicleGroups(vehicleId: string): Promise<{
id: string;
groupId: string;
linkPath: string | null;
linkWid: string | null;
partCount: number;
isScraped: boolean;
group: {
groupCode: string;
name: string;
nameTr: string | null;
};
}[]> {
return this.prisma.pL24VehicleGroup.findMany({
where: { vehicleId },
include: {
group: {
select: {
groupCode: true,
name: true,
nameTr: true,
},
},
},
});
}
/**
* Save parts for a vehicle-group combination
*/
async saveVehicleGroupParts(
catalogId: string,
vehicleId: string,
groupId: string,
parts: PL24ApiPart[],
schemaImageUrl?: string,
schemaPicId?: string,
): Promise<void> {
// Save parts and link to vehicle
for (const part of parts) {
const partId = await this.getOrCreatePart(catalogId, groupId, part);
// Link part to vehicle
await this.prisma.pL24VehiclePart.upsert({
where: {
vehicleId_partId_groupId: { vehicleId, partId, groupId },
},
update: {
quantity: part.quantity || 1,
position: part.positionCode,
},
create: {
vehicleId,
partId,
groupId,
quantity: part.quantity || 1,
position: part.positionCode,
},
});
}
// Update vehicle-group with part count and schema image
const updateData: any = {
partCount: parts.length,
isScraped: true,
};
if (schemaImageUrl) {
updateData.schemaImageUrl = schemaImageUrl;
}
if (schemaPicId) {
updateData.schemaPicId = schemaPicId;
}
await this.prisma.pL24VehicleGroup.update({
where: { vehicleId_groupId: { vehicleId, groupId } },
data: updateData,
});
this.logger.log(`Saved ${parts.length} parts for vehicle-group`);
}
/**
* Get or create a part at catalog level
*/
private async getOrCreatePart(
catalogId: string,
groupId: string,
part: PL24ApiPart,
): Promise<string> {
const partNumberClean = cleanPartNumber(part.oemCode);
const uniqueKey = createUniqueKey(catalogId, partNumberClean);
// Compute hash for change detection
const partData = {
name: part.name,
description: part.description,
remark: part.remark,
modelCodes: part.modelCodes,
};
const dataHash = computeHash(partData);
let dbPart = await this.prisma.pL24Part.findUnique({
where: { uniqueKey },
});
if (!dbPart) {
dbPart = await this.prisma.pL24Part.create({
data: {
catalogId,
groupId,
partNumber: part.oemCode,
partNumberClean,
formattedPartNo: part.formattedPartNo,
name: part.name,
nameTr: part.name, // PL24 returns Turkish when lang=tr
description: part.description,
positionCode: part.positionCode,
remark: part.remark,
modelCodes: part.modelCodes,
quantity: part.quantity || 1,
uniqueKey,
dataHash,
},
});
} else if (dbPart.dataHash !== dataHash) {
// Update if data changed
await this.prisma.pL24Part.update({
where: { id: dbPart.id },
data: {
name: part.name,
description: part.description,
remark: part.remark,
modelCodes: part.modelCodes,
dataHash,
},
});
}
return dbPart.id;
}
/**
* Get parts for a vehicle-group from database
*/
async getVehicleGroupParts(
vehicleId: string,
groupId: string,
): Promise<{
parts: Array<{
id: string;
partNumber: string;
formattedPartNo: string | null;
name: string;
nameTr: string | null;
description: string | null;
positionCode: string | null;
remark: string | null;
modelCodes: string | null;
quantity: number;
}>;
schemaImageUrl: string | null;
schemaPicId: string | null;
} | null> {
const vehicleGroup = await this.prisma.pL24VehicleGroup.findUnique({
where: { vehicleId_groupId: { vehicleId, groupId } },
select: {
schemaImageUrl: true,
schemaPicId: true,
isScraped: true,
},
});
if (!vehicleGroup || !vehicleGroup.isScraped) {
return null;
}
const vehicleParts = await this.prisma.pL24VehiclePart.findMany({
where: { vehicleId, groupId },
include: {
part: true,
},
});
return {
parts: vehicleParts.map((vp) => ({
id: vp.part.id,
partNumber: vp.part.partNumber,
formattedPartNo: vp.part.formattedPartNo,
name: vp.part.name,
nameTr: vp.part.nameTr,
description: vp.part.description,
positionCode: vp.part.positionCode,
remark: vp.part.remark,
modelCodes: vp.part.modelCodes,
quantity: vp.quantity,
})),
schemaImageUrl: vehicleGroup.schemaImageUrl,
schemaPicId: vehicleGroup.schemaPicId,
};
}
/**
* Get or create schema pic (deduplicated image)
*/
async getOrCreateSchemaPic(
imageId: string,
localPath: string,
originalUrl: string,
width?: number,
height?: number,
fileSize?: number,
): Promise<string> {
let schemaPic = await this.prisma.pL24SchemaPic.findUnique({
where: { imageId },
});
if (!schemaPic) {
schemaPic = await this.prisma.pL24SchemaPic.create({
data: {
imageId,
localPath,
originalUrl,
width,
height,
fileSize,
downloadedAt: new Date(),
},
});
this.logger.log(`Created PL24 schema pic: ${imageId}`);
}
return schemaPic.id;
}
/**
* Find schema pic by image ID
*/
async findSchemaPic(imageId: string): Promise<{
id: string;
localPath: string;
} | null> {
return this.prisma.pL24SchemaPic.findUnique({
where: { imageId },
select: { id: true, localPath: true },
});
}
}

View File

@@ -3,16 +3,19 @@
*
* NestJS module for partslink24.com VIN integration.
* Provides PL24Service for VIN decoding and on-demand parts fetching.
* PL24DbService handles normalized database storage with deduplication.
*/
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from '../../prisma/prisma.module';
import { PL24AuthService } from './pl24-auth.service';
import { PL24Service } from './pl24.service';
import { PL24DbService } from './pl24-db.service';
@Module({
imports: [ConfigModule],
providers: [PL24AuthService, PL24Service],
exports: [PL24Service, PL24AuthService],
imports: [ConfigModule, PrismaModule],
providers: [PL24AuthService, PL24Service, PL24DbService],
exports: [PL24Service, PL24AuthService, PL24DbService],
})
export class PL24Module {}

View File

@@ -380,6 +380,8 @@ export class PL24Service {
/**
* Fetch sub-groups using a direct link path from main group
* For service parts (servicepart/vin_groups), we need to construct linkPaths manually
* because the API doesn't return links for sub-groups
*/
async fetchSubGroupsByPath(
linkPath: string,
@@ -402,7 +404,25 @@ export class PL24Service {
}
const data = await response.json();
return this.parseSubGroupsResponse(data);
const subGroups = this.parseSubGroupsResponse(data);
// For service parts (vin_groups endpoint), construct linkPaths manually
// Service parts API doesn't return links, but we can build them from the parent path
const isServiceParts = linkPath.includes('servicepart/vin_groups');
if (isServiceParts && subGroups.length > 0) {
// Convert vin_groups to vin_items and add servicePartSubGroupId parameter
const basePath = linkPath.replace('vin_groups', 'vin_items');
for (const sg of subGroups) {
if (!sg.linkPath && sg.id) {
sg.linkPath = `${basePath}&servicePartSubGroupId=${sg.id}`;
sg.linkWid = 'servicePartsItemsTable';
this.logger.log(`Constructed service parts link for ${sg.id}: ${sg.linkPath}`);
}
}
}
return subGroups;
} catch (error) {
const err = error as Error;
this.logger.error(`Fetch sub-groups by path error: ${err.message}`);
@@ -879,6 +899,7 @@ export class PL24Service {
fileSize: number;
width: number | null;
height: number | null;
hotspots: Array<{ key: string; areas: Array<{ left: number; top: number; width: number; height: number }> }> | null;
} | null> {
if (!imageUrl) {
return null;
@@ -915,14 +936,30 @@ export class PL24Service {
let extension = '.png'; // Default to PNG
let width: number | null = null;
let height: number | null = null;
let hotspots: Array<{ key: string; areas: Array<{ left: number; top: number; width: number; height: number }> }> | null = null;
// PL24 returns images as base64-encoded JSON: { image: "base64string", originalWidth, originalHeight, ... }
// PL24 returns images as base64-encoded JSON: { image: "base64string", originalWidth, originalHeight, hotspots: [...] }
if (contentType.includes('application/json')) {
const jsonData = await response.json();
if (jsonData.image && typeof jsonData.image === 'string') {
buffer = Buffer.from(jsonData.image, 'base64');
width = jsonData.originalWidth || jsonData.scaledWidth || null;
height = jsonData.originalHeight || jsonData.scaledHeight || null;
// Extract hotspots data
if (Array.isArray(jsonData.hotspots)) {
hotspots = jsonData.hotspots.map((hs: any) => ({
key: hs.key,
areas: Array.isArray(hs.areas) ? hs.areas.map((area: any) => ({
left: area.left,
top: area.top,
width: area.width,
height: area.height,
})) : [],
}));
this.logger.log(`Extracted ${jsonData.hotspots.length} hotspots from image ${this.extractImageIdFromUrl(imageUrl)}`);
}
// PNG files start with specific magic bytes
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
extension = '.png';
@@ -973,6 +1010,7 @@ export class PL24Service {
fileSize: buffer.length,
width,
height,
hotspots,
};
} catch (error) {
const err = error as Error;
@@ -994,6 +1032,7 @@ export class PL24Service {
fileSize: number;
width: number | null;
height: number | null;
hotspots: Array<{ key: string; areas: Array<{ left: number; top: number; width: number; height: number }> }> | null;
} | null }> {
// Fetch parts first
const partsResponse = await this.fetchPartsByPath(linkPath, serviceName);
@@ -1005,6 +1044,7 @@ export class PL24Service {
fileSize: number;
width: number | null;
height: number | null;
hotspots: Array<{ key: string; areas: Array<{ left: number; top: number; width: number; height: number }> }> | null;
} | null = null;
if (partsResponse.schemaImageUrl) {
@@ -1016,7 +1056,7 @@ export class PL24Service {
return {
...partsResponse,
schemaImageResult, // Contains imageId, localPath, fileSize, width, height
schemaImageResult, // Contains imageId, localPath, fileSize, width, height, hotspots
};
}
}

View File

@@ -262,16 +262,38 @@ export interface PL24Part {
linkPath?: string;
}
/**
* Hotspot area coordinates on schema image
*/
export interface PL24HotspotArea {
left: number;
top: number;
width: number;
height: number;
descr?: string | null;
}
/**
* Hotspot position on schema image
* Each hotspot has a key (e.g., "1A", "2") that matches part's hotspotId
*/
export interface PL24Hotspot {
partId: string;
positionCode: string;
x: number;
y: number;
width?: number;
height?: number;
key: string; // Matches part's hotspotId (e.g., "1A", "2")
areas: PL24HotspotArea[]; // Clickable areas on the image
hotspotKeyLinks?: unknown[];
masks?: unknown[];
}
/**
* Image response from PL24 imageserver (JSON format)
*/
export interface PL24ImageResponse {
originalHeight: number;
originalWidth: number;
scaledHeight: number;
scaledWidth: number;
image: string; // Base64 encoded PNG
hotspots: PL24Hotspot[];
}
// ==================== API ENDPOINT TYPES ====================

View File

@@ -3,7 +3,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { PaginationDto, PaginatedResponseDto } from '../../common/dto/pagination.dto';
import { VehicleFilterDto } from './dto/vehicle-filter.dto';
import { PL24Service } from '../../integrations/pl24';
import { PL24Service, PL24DbService } from '../../integrations/pl24';
import { EmexService } from '../../integrations/emex';
// Type for PL24 catalog info stored in rawResponse
@@ -37,6 +37,8 @@ interface FetchedPart {
// Quantity (Unit column in PL24)
quantity?: number;
positionCode: string | null;
// Hotspot ID for schema image linking (e.g., "1A", "2")
hotspotId?: string;
// Model codes / PR codes for compatibility (e.g., "PR:1PD+F...FM4")
modelCodes?: string;
imageUrl: string | null;
@@ -49,6 +51,11 @@ interface SubGroupWithParts {
name: string;
schemaImageUrl: string | null;
parts: FetchedPart[];
partCount?: number; // Used when loading from DB (parts array may be empty)
// Schema image dimensions and hotspots (per-subgroup)
schemaWidth?: number | null;
schemaHeight?: number | null;
hotspots?: Array<{ key: string; areas: Array<{ left: number; top: number; width: number; height: number }> }> | null;
}
// EMEX category info stored in rawResponse
@@ -76,6 +83,7 @@ export class VehiclesService {
private prisma: PrismaService,
private pl24Service: PL24Service,
private emexService: EmexService,
private pl24DbService: PL24DbService,
) {}
async getUserVehicles(userId: string, pagination: PaginationDto, filter: VehicleFilterDto) {
@@ -327,6 +335,36 @@ export class VehiclesService {
// Get rawResponse from vehicle for source-specific data
const rawResponse = vehicle.rawResponse as VehicleRawResponse | null;
// If parts exist in DB, load subGroups from stored JSON
if (total > 0 && vehicleCategory?.subGroupsJson) {
try {
const storedSubGroups = vehicleCategory.subGroupsJson as Array<{
id: string;
code: string;
name: string;
schemaImageUrl: string | null;
partCount: number;
schemaWidth?: number | null;
schemaHeight?: number | null;
hotspots?: Array<{ key: string; areas: Array<{ left: number; top: number; width: number; height: number }> }> | null;
}>;
// Convert stored format to SubGroupWithParts (without parts array since they're paginated separately)
subGroups = storedSubGroups.map(sg => ({
id: sg.id,
code: sg.code,
name: sg.name,
schemaImageUrl: sg.schemaImageUrl,
parts: [], // Parts are loaded separately with pagination
partCount: sg.partCount, // Use stored partCount
schemaWidth: sg.schemaWidth || null,
schemaHeight: sg.schemaHeight || null,
hotspots: sg.hotspots || null,
}));
} catch (e) {
this.logger.warn(`Failed to parse subGroupsJson: ${(e as Error).message}`);
}
}
// If no parts found, fetch on-demand based on source (PL24 or EMEX)
if (total === 0) {
if (rawResponse?.source === 'pl24' && rawResponse.catalogInfo) {
@@ -343,6 +381,19 @@ export class VehiclesService {
parts = fetchResult.allParts.slice(skip, skip + limit) as any;
total = fetchResult.allParts.length;
subGroups = fetchResult.subGroups;
// Reload vehicleCategory to get updated schemaPicId and hotspots
vehicleCategory = await this.prisma.vehicleCategory.findUnique({
where: {
vehicleId_categoryId: {
vehicleId: vehicle.id,
categoryId: categoryId,
},
},
include: {
schemaPic: true,
},
});
}
} else if (rawResponse?.source === 'emex' && rawResponse.emexCategories) {
this.logger.log(`No parts in DB for VIN ${vin}, category ${category.code}. Fetching from EMEX...`);
@@ -361,10 +412,15 @@ export class VehiclesService {
}
}
// Get the schema image URL based on whether parts are from DB or freshly fetched
// Get the schema image URL - prefer local path if available
let schemaImageUrl: string | null = null;
if (subGroups.length > 0) {
// First priority: local path from SchemaPic (already downloaded and cached)
if (vehicleCategory?.schemaPic?.localPath) {
// Local path needs to go through /api/ route for reverse proxy
// Convert /images/schemas/... to /api/static/images/schemas/...
schemaImageUrl = `/api/static${vehicleCategory.schemaPic.localPath}`;
} else if (subGroups.length > 0) {
// Parts were just fetched from PL24 - use proxy URL for PL24 image
const originalUrl = subGroups[0]?.schemaImageUrl || null;
if (originalUrl && originalUrl.startsWith('http')) {
@@ -373,16 +429,9 @@ export class VehiclesService {
} else {
schemaImageUrl = originalUrl;
}
} else if (vehicleCategory) {
// Parts are from DB - prefer local path from linked SchemaPic if available
if (vehicleCategory.schemaPic?.localPath) {
// Local path needs to go through /api/ route for reverse proxy
// Convert /images/schemas/... to /api/static/images/schemas/...
schemaImageUrl = `/api/static${vehicleCategory.schemaPic.localPath}`;
} else if (vehicleCategory.schemaImageUrl) {
// Use proxy endpoint for PL24 URL
schemaImageUrl = `/api/vehicles/${vin}/schema-image?url=${encodeURIComponent(vehicleCategory.schemaImageUrl)}`;
}
} else if (vehicleCategory?.schemaImageUrl) {
// Use proxy endpoint for PL24 URL
schemaImageUrl = `/api/vehicles/${vin}/schema-image?url=${encodeURIComponent(vehicleCategory.schemaImageUrl)}`;
}
// Fallback to category's default schema image
@@ -421,10 +470,10 @@ export class VehiclesService {
iconName: category.iconName,
schemaImageUrl: schemaImageUrl,
},
// PL24-like structure: sub-groups with individual diagrams
// PL24-like structure: sub-groups with individual diagrams, dimensions, and hotspots
subGroups: subGroups.map((sg) => {
let sgSchemaUrl = sg.schemaImageUrl;
// Convert PL24 URLs to proxy URLs
// Convert PL24 URLs to proxy URLs or local paths
if (sgSchemaUrl && sgSchemaUrl.startsWith('http')) {
sgSchemaUrl = `/api/vehicles/${vin}/schema-image?url=${encodeURIComponent(sgSchemaUrl)}`;
}
@@ -433,7 +482,11 @@ export class VehiclesService {
code: sg.code,
name: sg.name,
schemaImageUrl: sgSchemaUrl,
partCount: sg.parts.length,
partCount: sg.partCount ?? sg.parts.length, // Use stored partCount or calculate from parts
// Schema image dimensions and hotspots (per-subgroup)
schemaWidth: sg.schemaWidth || null,
schemaHeight: sg.schemaHeight || null,
hotspots: sg.hotspots || null,
};
}),
parts: parts.map((part) => {
@@ -450,11 +503,22 @@ export class VehiclesService {
remark: p.remark || null,
quantity: p.quantity || null,
positionCode: p.positionCode,
hotspotId: p.hotspotId || null, // For linking to schema image hotspot
modelCodes: p.modelCodes || null,
imageUrl: p.imageUrl,
prices: (p.brandPrices as Array<{ brand: string; price: number; currency: string; inStock: boolean }>) || [],
};
}),
// Hotspot coordinates for schema image interaction
hotspots: vehicleCategory?.schemaPic?.hotspots as Array<{
key: string;
areas: Array<{ left: number; top: number; width: number; height: number }>;
}> || null,
// Image dimensions for calculating hotspot positions
schemaImageDimensions: vehicleCategory?.schemaPic ? {
width: vehicleCategory.schemaPic.width,
height: vehicleCategory.schemaPic.height,
} : null,
totalParts: total,
};
}
@@ -575,20 +639,20 @@ export class VehiclesService {
const subGroupsToFetch = subGroups.slice(0, 10);
// Track original PL24 URL for immediate display and async download
// Track original PL24 URL for the main category schema
let originalSchemaUrl: string | null = null;
for (const subGroup of subGroupsToFetch) {
if (subGroup.linkPath) {
try {
// Fetch parts WITHOUT downloading image - use original PL24 URL for immediate display
// Fetch parts for this subgroup
const partsResponse = await this.pl24Service.fetchPartsByPath(
subGroup.linkPath,
catalogInfo.serviceName,
);
if (partsResponse.parts && partsResponse.parts.length > 0) {
// Store original PL24 URL for immediate display
// Store first subgroup's URL as main category schema
if (partsResponse.schemaImageUrl && !originalSchemaUrl) {
originalSchemaUrl = partsResponse.schemaImageUrl;
}
@@ -609,6 +673,7 @@ export class VehiclesService {
remark: p.remark,
quantity: p.quantity,
positionCode: p.positionCode || null,
hotspotId: p.hotspotId || undefined, // Link to schema image hotspot
modelCodes: p.modelCodes,
imageUrl: null,
brandPrices: [],
@@ -617,14 +682,68 @@ export class VehiclesService {
subGroupParts.push(partData);
}
// Add subgroup with its parts and schema image
// Use original PL24 URL for immediate display
// Download schema image for this subgroup (each subgroup has its own image)
let subGroupLocalPath: string | null = null;
let subGroupSchemaWidth: number | null = null;
let subGroupSchemaHeight: number | null = null;
let subGroupHotspots: Array<{ key: string; areas: Array<{ left: number; top: number; width: number; height: number }> }> | null = null;
if (partsResponse.schemaImageUrl) {
try {
const imageId = this.pl24Service.extractImageIdFromUrl(partsResponse.schemaImageUrl);
if (imageId) {
// Check if already exists
let schemaPic = await this.prisma.schemaPic.findUnique({
where: { imageId },
});
if (schemaPic) {
this.logger.log(`Schema image ${imageId} already exists for subgroup ${subGroup.name}`);
subGroupLocalPath = `/api/static${schemaPic.localPath}`;
subGroupSchemaWidth = schemaPic.width;
subGroupSchemaHeight = schemaPic.height;
subGroupHotspots = schemaPic.hotspots as typeof subGroupHotspots;
} else {
// Download new image
const downloadResult = await this.pl24Service.downloadSchemaImage(
partsResponse.schemaImageUrl,
catalogInfo.serviceName,
);
if (downloadResult) {
schemaPic = await this.prisma.schemaPic.create({
data: {
imageId: downloadResult.imageId,
localPath: downloadResult.localPath,
originalUrl: partsResponse.schemaImageUrl,
fileSize: downloadResult.fileSize,
width: downloadResult.width,
height: downloadResult.height,
hotspots: downloadResult.hotspots || undefined,
},
});
this.logger.log(`Downloaded schema image for subgroup ${subGroup.name}: ${downloadResult.localPath}`);
subGroupLocalPath = `/api/static${downloadResult.localPath}`;
subGroupSchemaWidth = downloadResult.width;
subGroupSchemaHeight = downloadResult.height;
subGroupHotspots = downloadResult.hotspots as typeof subGroupHotspots;
}
}
}
} catch (imgError) {
this.logger.warn(`Failed to download schema for subgroup ${subGroup.name}: ${(imgError as Error).message}`);
}
}
// Add subgroup with its parts, schema image, dimensions and hotspots
subGroupsWithParts.push({
id: subGroup.id,
code: subGroup.code,
name: subGroup.name,
schemaImageUrl: partsResponse.schemaImageUrl || null,
schemaImageUrl: subGroupLocalPath || partsResponse.schemaImageUrl || null,
parts: subGroupParts,
schemaWidth: subGroupSchemaWidth,
schemaHeight: subGroupSchemaHeight,
hotspots: subGroupHotspots,
});
}
} catch (error) {
@@ -650,14 +769,15 @@ export class VehiclesService {
nameTr: p.nameTr,
description: p.description,
positionCode: p.positionCode,
hotspotId: p.hotspotId || null, // Link to schema image hotspot
brandPrices: JSON.parse(JSON.stringify(p.brandPrices)),
imageUrl: p.imageUrl,
})),
skipDuplicates: true,
});
// Update vehicle category part count and save original schema URL
const updateData: { partCount: number; schemaImageUrl?: string } = {
// Update vehicle category part count, schema URL, and subGroups
const updateData: { partCount: number; schemaImageUrl?: string; subGroupsJson?: any } = {
partCount: allParts.length,
};
@@ -666,6 +786,20 @@ export class VehiclesService {
updateData.schemaImageUrl = originalSchemaUrl;
}
// Save subGroups info for future queries (without parts array to reduce size)
if (subGroupsWithParts.length > 0) {
updateData.subGroupsJson = subGroupsWithParts.map(sg => ({
id: sg.id,
code: sg.code,
name: sg.name,
schemaImageUrl: sg.schemaImageUrl,
partCount: sg.parts.length,
schemaWidth: sg.schemaWidth || null,
schemaHeight: sg.schemaHeight || null,
hotspots: sg.hotspots || null,
}));
}
await tx.vehicleCategory.updateMany({
where: {
vehicleId: vehicle.id,
@@ -683,50 +817,23 @@ export class VehiclesService {
});
});
// Handle schema image with deduplication via SchemaPic table
// Same image can be used across multiple vehicles, so we store once and link
// Link VehicleCategory to the first subgroup's SchemaPic (for the main category image)
let localSchemaPath: string | null = null;
let pl24SchemaPicId: string | null = null;
if (originalSchemaUrl) {
try {
// Extract imageId from URL for deduplication
const imageId = this.pl24Service.extractImageIdFromUrl(originalSchemaUrl);
if (imageId) {
// Check if SchemaPic already exists
let schemaPic = await this.prisma.schemaPic.findUnique({
// Find the SchemaPic we already created during subgroup processing
const schemaPic = await this.prisma.schemaPic.findUnique({
where: { imageId },
});
if (schemaPic) {
// Image already downloaded, just link to it
this.logger.log(`Schema image ${imageId} already exists, linking to VehicleCategory`);
localSchemaPath = schemaPic.localPath;
} else {
// Download new image
const downloadResult = await this.pl24Service.downloadSchemaImage(
originalSchemaUrl,
catalogInfo.serviceName,
);
if (downloadResult) {
// Create SchemaPic record
schemaPic = await this.prisma.schemaPic.create({
data: {
imageId: downloadResult.imageId,
localPath: downloadResult.localPath,
originalUrl: originalSchemaUrl,
fileSize: downloadResult.fileSize,
width: downloadResult.width,
height: downloadResult.height,
},
});
this.logger.log(`Schema image saved: ${downloadResult.localPath} (${downloadResult.fileSize} bytes)`);
localSchemaPath = downloadResult.localPath;
}
}
// Link VehicleCategory to SchemaPic
if (schemaPic) {
// Link VehicleCategory to the main SchemaPic
await this.prisma.vehicleCategory.updateMany({
where: {
vehicleId: vehicle.id,
@@ -737,23 +844,66 @@ export class VehiclesService {
},
});
this.logger.log(`VehicleCategory linked to SchemaPic: ${schemaPic.id}`);
}
// Update subGroups to use local path
if (localSchemaPath) {
subGroupsWithParts.forEach(sg => {
if (sg.schemaImageUrl) {
sg.schemaImageUrl = `/api/static${localSchemaPath}`;
}
});
// Also save to PL24 normalized schema pic table
try {
pl24SchemaPicId = await this.pl24DbService.getOrCreateSchemaPic(
imageId,
localSchemaPath,
originalSchemaUrl,
schemaPic.width || undefined,
schemaPic.height || undefined,
schemaPic.fileSize || undefined,
);
} catch (err) {
this.logger.warn(`Failed to save PL24 schema pic: ${(err as Error).message}`);
}
}
}
} catch (error) {
this.logger.warn(`Failed to handle schema image: ${(error as Error).message}`);
// Fall through - will use proxy URL as fallback
this.logger.warn(`Failed to link schema image: ${(error as Error).message}`);
}
}
// Save parts to PL24 normalized tables for deduplication
try {
const pl24VinData = await this.pl24DbService.findVehicleByVin(vehicle.vin);
if (pl24VinData) {
const pl24VehicleId = pl24VinData.vehicle.id;
// Find the group ID for this category
const vehicleGroups = await this.pl24DbService.getVehicleGroups(pl24VehicleId);
const matchingGroup = vehicleGroups.find(vg => vg.group.groupCode === category.code);
if (matchingGroup) {
// Convert parts to PL24ApiPart format
const pl24Parts = allParts.map(p => ({
id: p.id,
oemCode: p.oemCode,
formattedPartNo: p.formattedPartNo,
name: p.nameEn,
description: p.description || undefined,
positionCode: p.positionCode || undefined,
remark: p.remark || undefined,
modelCodes: p.modelCodes || undefined,
quantity: p.quantity,
}));
await this.pl24DbService.saveVehicleGroupParts(
pl24VinData.vehicle.catalogId,
pl24VehicleId,
matchingGroup.groupId,
pl24Parts,
originalSchemaUrl || undefined,
pl24SchemaPicId || undefined,
);
this.logger.log(`Saved ${pl24Parts.length} parts to PL24 normalized tables`);
}
}
} catch (error) {
this.logger.warn(`Failed to save parts to PL24 normalized tables: ${(error as Error).message}`);
// Continue - legacy save was successful
}
return {
allParts: savedParts as any as FetchedPart[],
subGroups: subGroupsWithParts,

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>