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>
BIN
apps/api/public/images/schemas/121800000.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
apps/api/public/images/schemas/194500200.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
apps/api/public/images/schemas/194711200.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
apps/api/public/images/schemas/194711500.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
apps/api/public/images/schemas/194721250.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
apps/api/public/images/schemas/194721600.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
apps/api/public/images/schemas/194803100.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
apps/api/public/images/schemas/194803300.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
apps/api/public/images/schemas/194803350.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
apps/api/public/images/schemas/194803500.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
apps/api/public/images/schemas/194804100.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
apps/api/public/images/schemas/201805000.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
apps/api/public/images/schemas/203201050.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
apps/api/public/images/schemas/203300100.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
apps/api/public/images/schemas/206803700.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/api/public/images/schemas/266407000.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
apps/api/public/images/schemas/460100150.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
apps/api/public/images/schemas/631903000.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/api/public/images/schemas/694201050.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
apps/api/public/images/schemas/717300550.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
apps/api/public/images/schemas/717300820.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
apps/api/public/images/schemas/717317530.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
apps/api/public/images/schemas/717321610.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
apps/api/public/images/schemas/719601120.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/api/public/images/schemas/723903200.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/api/public/images/schemas/736911100.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
apps/api/public/images/schemas/745100050.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
apps/api/public/images/schemas/745411500.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
apps/api/public/images/schemas/745903000.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
apps/api/public/images/schemas/798325810.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
apps/api/public/images/schemas/798409055.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
apps/api/public/images/schemas/806800100.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
apps/api/public/images/schemas/806800110.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
apps/api/public/images/schemas/849311095.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/api/public/images/schemas/849311100.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
apps/api/public/images/schemas/849311970.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/api/public/images/schemas/849325100.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
apps/api/public/images/schemas/849325801.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
apps/api/public/images/schemas/849407000.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/api/public/images/schemas/849411000.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
apps/api/public/images/schemas/849411400.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/api/public/images/schemas/849419100.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
apps/api/public/images/schemas/849419500.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/api/public/images/schemas/849423000.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
apps/api/public/images/schemas/849713100.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
apps/api/public/images/schemas/849721700.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
apps/api/public/images/schemas/849723250.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
apps/api/public/images/schemas/849803000.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
apps/api/public/images/schemas/849803100.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
apps/api/public/images/schemas/849803340.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
apps/api/public/images/schemas/849803350.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
apps/api/public/images/schemas/849803360.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
apps/api/public/images/schemas/849803400.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
apps/api/public/images/schemas/849803500.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
apps/api/public/images/schemas/849804000.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
apps/api/public/images/schemas/849804500.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
apps/api/public/images/schemas/849805000.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
apps/api/public/images/schemas/849905000.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
apps/api/public/images/schemas/849906000.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
apps/api/public/images/schemas/849906003.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
apps/api/public/images/schemas/849907000.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/api/public/images/schemas/849907020.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/api/public/images/schemas/849907450.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
apps/api/public/images/schemas/849909000.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
apps/api/public/images/schemas/859407200.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
apps/api/public/images/schemas/897201000.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
@@ -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';
|
||||
|
||||
600
apps/api/src/integrations/pl24/pl24-db.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ====================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||