feat: EMEX catalog integration — schema, data migration, backend & frontend
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
Redesign emex_* tables from vehicle-centric to catalog-centric structure with junction tables (emex_vehicle_group_links, emex_vehicle_part_links). Migrate 92M+ rows from emex DB via postgres_fdw. Add EmexCatalogService for browse and VIN pre-scraped matching, with Redis caching. Frontend catalog page now has PL24/Emex tabs with full drill-down routes (brand → vehicle → group → parts). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CatalogController } from "./catalog.controller";
|
||||
import { CatalogService } from "./catalog.service";
|
||||
import { EmexCatalogController } from "./emex-catalog.controller";
|
||||
import { EmexCatalogService } from "./emex-catalog.service";
|
||||
import { PL24Module } from "../integrations/pl24/pl24.module";
|
||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||
import { StorageModule } from "../storage/storage.module";
|
||||
|
||||
@Module({
|
||||
imports: [PL24Module, SubscriptionsModule, StorageModule],
|
||||
controllers: [CatalogController],
|
||||
providers: [CatalogService],
|
||||
exports: [CatalogService],
|
||||
controllers: [CatalogController, EmexCatalogController],
|
||||
providers: [CatalogService, EmexCatalogService],
|
||||
exports: [CatalogService, EmexCatalogService],
|
||||
})
|
||||
export class CatalogModule {}
|
||||
|
||||
40
apps/api/src/catalog/emex-catalog.controller.ts
Normal file
40
apps/api/src/catalog/emex-catalog.controller.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Controller, Get, Param, Query } from "@nestjs/common";
|
||||
import { EmexCatalogService } from "./emex-catalog.service";
|
||||
|
||||
@Controller("catalog/emex")
|
||||
export class EmexCatalogController {
|
||||
constructor(private emexCatalogService: EmexCatalogService) {}
|
||||
|
||||
@Get("brands")
|
||||
getBrands() {
|
||||
return this.emexCatalogService.getBrands();
|
||||
}
|
||||
|
||||
@Get("brands/:code/vehicles")
|
||||
getVehicles(@Param("code") code: string) {
|
||||
return this.emexCatalogService.getVehicles(code);
|
||||
}
|
||||
|
||||
@Get("vehicles/:id/groups")
|
||||
getVehicleGroups(@Param("id") id: string) {
|
||||
return this.emexCatalogService.getVehicleGroups(id);
|
||||
}
|
||||
|
||||
@Get("vehicles/:id/groups/:groupId")
|
||||
getGroupParts(@Param("id") id: string, @Param("groupId") groupId: string) {
|
||||
return this.emexCatalogService.getGroupParts(id, groupId);
|
||||
}
|
||||
|
||||
@Get("search")
|
||||
searchByOem(@Query("oem") oem: string) {
|
||||
return this.emexCatalogService.searchByOem(oem);
|
||||
}
|
||||
|
||||
@Get("match")
|
||||
matchByName(
|
||||
@Query("catalogCode") catalogCode: string,
|
||||
@Query("name") name: string,
|
||||
) {
|
||||
return this.emexCatalogService.matchByName(catalogCode, name);
|
||||
}
|
||||
}
|
||||
388
apps/api/src/catalog/emex-catalog.service.ts
Normal file
388
apps/api/src/catalog/emex-catalog.service.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { eq, and, sql, ilike, or } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import {
|
||||
emexCatalogs,
|
||||
emexVehicles,
|
||||
emexPartGroups,
|
||||
emexParts,
|
||||
emexPartNumbers,
|
||||
emexSchemaPics,
|
||||
emexVehicleGroupLinks,
|
||||
emexVehiclePartLinks,
|
||||
} from "../database/schema/emex";
|
||||
import { RedisService } from "../redis/redis.service";
|
||||
|
||||
export interface EmexBrandDto {
|
||||
id: string;
|
||||
catalogId: string;
|
||||
code: string | null;
|
||||
brandName: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface EmexVehicleDto {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
name: string | null;
|
||||
engine: string | null;
|
||||
engineCode: string | null;
|
||||
bodyType: string | null;
|
||||
transmission: string | null;
|
||||
driveType: string | null;
|
||||
fuelType: string | null;
|
||||
yearFrom: number | null;
|
||||
yearTo: number | null;
|
||||
optionsRaw: string | null;
|
||||
}
|
||||
|
||||
export interface EmexGroupDto {
|
||||
id: string;
|
||||
groupId: string;
|
||||
name: string;
|
||||
nameOriginal: string | null;
|
||||
parentGroupId: string | null;
|
||||
sortOrder: number | null;
|
||||
hasParts: boolean | null;
|
||||
hasChildren: boolean | null;
|
||||
}
|
||||
|
||||
interface EmexPartDto {
|
||||
id: string;
|
||||
partNumber: string | null;
|
||||
name: string;
|
||||
nameOriginal: string | null;
|
||||
description: string | null;
|
||||
oemNumber: string | null;
|
||||
hotspotIndex: number | null;
|
||||
quantity: number | null;
|
||||
position: string | null;
|
||||
}
|
||||
|
||||
interface EmexSchemaPicDto {
|
||||
id: string;
|
||||
imageUrl: string | null;
|
||||
localPath: string | null;
|
||||
hotspots: unknown;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
sortOrder: number | null;
|
||||
}
|
||||
|
||||
export interface EmexGroupPartsDto {
|
||||
group: EmexGroupDto;
|
||||
parts: EmexPartDto[];
|
||||
schemaPics: EmexSchemaPicDto[];
|
||||
}
|
||||
|
||||
export interface EmexVehicleMatch {
|
||||
vehicleId: string;
|
||||
catalogCode: string;
|
||||
vehicleName: string | null;
|
||||
candidates: EmexVehicleDto[];
|
||||
}
|
||||
|
||||
export interface EmexSearchResult {
|
||||
partId: string;
|
||||
partNumber: string | null;
|
||||
name: string;
|
||||
catalogCode: string | null;
|
||||
brandName: string | null;
|
||||
groupName: string | null;
|
||||
}
|
||||
|
||||
const CACHE_TTL = {
|
||||
brands: 86400, // 24h
|
||||
vehicles: 43200, // 12h
|
||||
groups: 21600, // 6h
|
||||
parts: 7200, // 2h
|
||||
match: 3600, // 1h
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class EmexCatalogService {
|
||||
private readonly logger = new Logger(EmexCatalogService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private redis: RedisService,
|
||||
) {}
|
||||
|
||||
// ── Katalog Browse ──────────────────────────────────
|
||||
|
||||
async getBrands(): Promise<EmexBrandDto[]> {
|
||||
const cacheKey = "emex:brands";
|
||||
const cached = await this.redis.getJson<EmexBrandDto[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: emexCatalogs.id,
|
||||
catalogId: emexCatalogs.catalogId,
|
||||
code: emexCatalogs.code,
|
||||
brandName: emexCatalogs.brandName,
|
||||
description: emexCatalogs.description,
|
||||
})
|
||||
.from(emexCatalogs)
|
||||
.orderBy(emexCatalogs.brandName);
|
||||
|
||||
await this.redis.setJson(cacheKey, rows, CACHE_TTL.brands);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getVehicles(catalogCode: string): Promise<EmexVehicleDto[]> {
|
||||
const cacheKey = `emex:vehicles:${catalogCode}`;
|
||||
const cached = await this.redis.getJson<EmexVehicleDto[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const catalog = await this.db
|
||||
.select({ id: emexCatalogs.id })
|
||||
.from(emexCatalogs)
|
||||
.where(eq(emexCatalogs.catalogId, catalogCode))
|
||||
.limit(1);
|
||||
|
||||
if (catalog.length === 0) return [];
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: emexVehicles.id,
|
||||
vehicleId: emexVehicles.vehicleId,
|
||||
name: emexVehicles.name,
|
||||
engine: emexVehicles.engine,
|
||||
engineCode: emexVehicles.engineCode,
|
||||
bodyType: emexVehicles.bodyType,
|
||||
transmission: emexVehicles.transmission,
|
||||
driveType: emexVehicles.driveType,
|
||||
fuelType: emexVehicles.fuelType,
|
||||
yearFrom: emexVehicles.yearFrom,
|
||||
yearTo: emexVehicles.yearTo,
|
||||
optionsRaw: emexVehicles.optionsRaw,
|
||||
})
|
||||
.from(emexVehicles)
|
||||
.where(eq(emexVehicles.catalogId, catalog[0].id))
|
||||
.orderBy(emexVehicles.name);
|
||||
|
||||
await this.redis.setJson(cacheKey, rows, CACHE_TTL.vehicles);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getVehicleGroups(vehicleId: string): Promise<EmexGroupDto[]> {
|
||||
const cacheKey = `emex:groups:${vehicleId}`;
|
||||
const cached = await this.redis.getJson<EmexGroupDto[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: emexPartGroups.id,
|
||||
groupId: emexPartGroups.groupId,
|
||||
name: emexPartGroups.name,
|
||||
nameOriginal: emexPartGroups.nameOriginal,
|
||||
parentGroupId: emexPartGroups.parentGroupId,
|
||||
sortOrder: emexPartGroups.sortOrder,
|
||||
hasParts: emexPartGroups.hasParts,
|
||||
hasChildren: emexPartGroups.hasChildren,
|
||||
})
|
||||
.from(emexVehicleGroupLinks)
|
||||
.innerJoin(emexPartGroups, eq(emexVehicleGroupLinks.emexGroupId, emexPartGroups.id))
|
||||
.where(eq(emexVehicleGroupLinks.emexVehicleId, vehicleId))
|
||||
.orderBy(emexPartGroups.sortOrder, emexPartGroups.name);
|
||||
|
||||
await this.redis.setJson(cacheKey, rows, CACHE_TTL.groups);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getGroupParts(vehicleId: string, groupId: string): Promise<EmexGroupPartsDto> {
|
||||
const cacheKey = `emex:parts:${vehicleId}:${groupId}`;
|
||||
const cached = await this.redis.getJson<EmexGroupPartsDto>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
// Get group info
|
||||
const [group] = await this.db
|
||||
.select({
|
||||
id: emexPartGroups.id,
|
||||
groupId: emexPartGroups.groupId,
|
||||
name: emexPartGroups.name,
|
||||
nameOriginal: emexPartGroups.nameOriginal,
|
||||
parentGroupId: emexPartGroups.parentGroupId,
|
||||
sortOrder: emexPartGroups.sortOrder,
|
||||
hasParts: emexPartGroups.hasParts,
|
||||
hasChildren: emexPartGroups.hasChildren,
|
||||
})
|
||||
.from(emexPartGroups)
|
||||
.where(eq(emexPartGroups.id, groupId))
|
||||
.limit(1);
|
||||
|
||||
if (!group) {
|
||||
return { group: null as unknown as EmexGroupDto, parts: [], schemaPics: [] };
|
||||
}
|
||||
|
||||
// Get parts for this vehicle+group via junction
|
||||
const parts = await this.db
|
||||
.select({
|
||||
id: emexParts.id,
|
||||
partNumber: emexParts.partNumber,
|
||||
name: emexParts.name,
|
||||
nameOriginal: emexParts.nameOriginal,
|
||||
description: emexParts.description,
|
||||
oemNumber: emexParts.oemNumber,
|
||||
hotspotIndex: emexParts.hotspotIndex,
|
||||
quantity: emexVehiclePartLinks.quantity,
|
||||
position: emexVehiclePartLinks.position,
|
||||
})
|
||||
.from(emexVehiclePartLinks)
|
||||
.innerJoin(emexParts, eq(emexVehiclePartLinks.emexPartId, emexParts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(emexVehiclePartLinks.emexVehicleId, vehicleId),
|
||||
eq(emexVehiclePartLinks.emexGroupId, groupId),
|
||||
),
|
||||
)
|
||||
.orderBy(emexParts.hotspotIndex, emexParts.name);
|
||||
|
||||
// Get schema pics for this group
|
||||
const schemaPics = await this.db
|
||||
.select({
|
||||
id: emexSchemaPics.id,
|
||||
imageUrl: emexSchemaPics.imageUrl,
|
||||
localPath: emexSchemaPics.localPath,
|
||||
hotspots: emexSchemaPics.hotspots,
|
||||
width: emexSchemaPics.width,
|
||||
height: emexSchemaPics.height,
|
||||
sortOrder: emexSchemaPics.sortOrder,
|
||||
})
|
||||
.from(emexSchemaPics)
|
||||
.where(eq(emexSchemaPics.groupId, groupId))
|
||||
.orderBy(emexSchemaPics.sortOrder);
|
||||
|
||||
const result: EmexGroupPartsDto = { group, parts, schemaPics };
|
||||
await this.redis.setJson(cacheKey, result, CACHE_TTL.parts);
|
||||
return result;
|
||||
}
|
||||
|
||||
async searchByOem(query: string): Promise<EmexSearchResult[]> {
|
||||
if (!query || query.length < 3) return [];
|
||||
|
||||
const cleanQuery = query.replace(/[-\s.]/g, "").toUpperCase();
|
||||
|
||||
// Search in emex_part_numbers
|
||||
const results = await this.db
|
||||
.select({
|
||||
partId: emexParts.id,
|
||||
partNumber: emexParts.partNumber,
|
||||
name: emexParts.name,
|
||||
catalogCode: emexCatalogs.code,
|
||||
brandName: emexCatalogs.brandName,
|
||||
groupName: emexPartGroups.name,
|
||||
})
|
||||
.from(emexPartNumbers)
|
||||
.innerJoin(emexParts, eq(emexPartNumbers.emexPartId, emexParts.id))
|
||||
.leftJoin(emexCatalogs, eq(emexParts.emexCatalogId, emexCatalogs.id))
|
||||
.leftJoin(emexPartGroups, eq(emexParts.groupId, emexPartGroups.id))
|
||||
.where(
|
||||
or(
|
||||
ilike(emexPartNumbers.oemCode, `%${cleanQuery}%`),
|
||||
ilike(emexParts.partNumber, `%${cleanQuery}%`),
|
||||
ilike(emexParts.oemNumber, `%${cleanQuery}%`),
|
||||
),
|
||||
)
|
||||
.limit(50);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── VIN Decode Eslestirme ────────────────────────────
|
||||
|
||||
async matchByName(catalogCode: string, vehicleName: string): Promise<EmexVehicleMatch | null> {
|
||||
const nameHash = Buffer.from(vehicleName).toString("base64url").slice(0, 32);
|
||||
const cacheKey = `emex:match:${catalogCode}:${nameHash}`;
|
||||
const cached = await this.redis.getJson<EmexVehicleMatch>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
// Find catalog
|
||||
const [catalog] = await this.db
|
||||
.select({ id: emexCatalogs.id })
|
||||
.from(emexCatalogs)
|
||||
.where(eq(emexCatalogs.catalogId, catalogCode))
|
||||
.limit(1);
|
||||
|
||||
if (!catalog) return null;
|
||||
|
||||
// Search vehicles by name (exact or contains)
|
||||
const candidates = await this.db
|
||||
.select({
|
||||
id: emexVehicles.id,
|
||||
vehicleId: emexVehicles.vehicleId,
|
||||
name: emexVehicles.name,
|
||||
engine: emexVehicles.engine,
|
||||
engineCode: emexVehicles.engineCode,
|
||||
bodyType: emexVehicles.bodyType,
|
||||
transmission: emexVehicles.transmission,
|
||||
driveType: emexVehicles.driveType,
|
||||
fuelType: emexVehicles.fuelType,
|
||||
yearFrom: emexVehicles.yearFrom,
|
||||
yearTo: emexVehicles.yearTo,
|
||||
optionsRaw: emexVehicles.optionsRaw,
|
||||
})
|
||||
.from(emexVehicles)
|
||||
.where(
|
||||
and(
|
||||
eq(emexVehicles.catalogId, catalog.id),
|
||||
eq(emexVehicles.name, vehicleName),
|
||||
),
|
||||
)
|
||||
.orderBy(emexVehicles.optionsRaw);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
// Try partial match: extract model name before brackets
|
||||
const modelMatch = vehicleName.match(/^([^\[]+)/);
|
||||
if (modelMatch) {
|
||||
const modelName = modelMatch[1].trim();
|
||||
const partialCandidates = await this.db
|
||||
.select({
|
||||
id: emexVehicles.id,
|
||||
vehicleId: emexVehicles.vehicleId,
|
||||
name: emexVehicles.name,
|
||||
engine: emexVehicles.engine,
|
||||
engineCode: emexVehicles.engineCode,
|
||||
bodyType: emexVehicles.bodyType,
|
||||
transmission: emexVehicles.transmission,
|
||||
driveType: emexVehicles.driveType,
|
||||
fuelType: emexVehicles.fuelType,
|
||||
yearFrom: emexVehicles.yearFrom,
|
||||
yearTo: emexVehicles.yearTo,
|
||||
optionsRaw: emexVehicles.optionsRaw,
|
||||
})
|
||||
.from(emexVehicles)
|
||||
.where(
|
||||
and(
|
||||
eq(emexVehicles.catalogId, catalog.id),
|
||||
ilike(emexVehicles.name, `${modelName}%`),
|
||||
),
|
||||
)
|
||||
.orderBy(emexVehicles.optionsRaw)
|
||||
.limit(50);
|
||||
|
||||
if (partialCandidates.length === 0) return null;
|
||||
|
||||
const match: EmexVehicleMatch = {
|
||||
vehicleId: partialCandidates[0].id,
|
||||
catalogCode,
|
||||
vehicleName,
|
||||
candidates: partialCandidates,
|
||||
};
|
||||
await this.redis.setJson(cacheKey, match, CACHE_TTL.match);
|
||||
return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const match: EmexVehicleMatch = {
|
||||
vehicleId: candidates[0].id,
|
||||
catalogCode,
|
||||
vehicleName,
|
||||
candidates,
|
||||
};
|
||||
await this.redis.setJson(cacheKey, match, CACHE_TTL.match);
|
||||
return match;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,10 @@ import { CategoriesService } from "./categories.service";
|
||||
import { PL24Module } from "../integrations/pl24/pl24.module";
|
||||
import { EmexModule } from "../integrations/emex/emex.module";
|
||||
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
|
||||
import { CatalogModule } from "../catalog/catalog.module";
|
||||
|
||||
@Module({
|
||||
imports: [PL24Module, EmexModule, PartsCatalogsModule],
|
||||
imports: [PL24Module, EmexModule, PartsCatalogsModule, CatalogModule],
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService],
|
||||
exports: [CategoriesService],
|
||||
|
||||
@@ -25,7 +25,11 @@ function createService(db: any) {
|
||||
const pl24FordLegacyService = {
|
||||
fetchCategoriesForPsaVin: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
const service = new CategoriesService(db as any, redis as any, pl24Service as any, emexService as any, partsCatalogsService as any, storage as any, pl24FordLegacyService as any);
|
||||
const emexCatalogService = {
|
||||
matchByName: vi.fn().mockResolvedValue(null),
|
||||
getVehicleGroups: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
const service = new CategoriesService(db as any, redis as any, pl24Service as any, emexService as any, emexCatalogService as any, partsCatalogsService as any, storage as any, pl24FordLegacyService as any);
|
||||
return { service, db, redis, pl24Service };
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RedisService } from "../redis/redis.service";
|
||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||
import { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
|
||||
import { EmexService } from "../integrations/emex/emex.service";
|
||||
import { EmexCatalogService } from "../catalog/emex-catalog.service";
|
||||
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
|
||||
import type { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
@@ -19,6 +20,7 @@ export class CategoriesService {
|
||||
private redis: RedisService,
|
||||
private pl24Service: PL24Service,
|
||||
private emexService: EmexService,
|
||||
private emexCatalogService: EmexCatalogService,
|
||||
private partsCatalogsService: PartsCatalogsService,
|
||||
private storage: StorageService,
|
||||
private pl24FordLegacyService: PL24FordLegacyService,
|
||||
@@ -203,9 +205,46 @@ export class CategoriesService {
|
||||
}
|
||||
}
|
||||
|
||||
// If still no categories, try EMEX fallback
|
||||
// If still no categories, try EMEX pre-scraped match first, then on-demand fallback
|
||||
if (dbCategories.length === 0 && vehicle.vin) {
|
||||
this.logger.log(`No PL24 categories for ${vehicle.vin}, trying EMEX fallback`);
|
||||
this.logger.log(`No PL24 categories for ${vehicle.vin}, trying EMEX pre-scraped match`);
|
||||
|
||||
// Try pre-scraped emex catalog match
|
||||
const vRawData = vehicle.rawData as Record<string, unknown> | null;
|
||||
const catalogCode = (vRawData?.catalogCode as string) || this.emexService.getCatalogCode(vehicle.vin);
|
||||
const vehicleName = (vRawData?.emexVehicleName as string) || (vRawData?.model as string);
|
||||
|
||||
if (catalogCode && vehicleName) {
|
||||
try {
|
||||
const match = await this.emexCatalogService.matchByName(catalogCode, vehicleName);
|
||||
if (match) {
|
||||
this.logger.log(`EMEX pre-scraped match found for ${vehicle.vin}: ${match.vehicleId}`);
|
||||
const groups = await this.emexCatalogService.getVehicleGroups(match.vehicleId);
|
||||
if (groups.length > 0) {
|
||||
// Convert emex groups to categories format for tree building
|
||||
const insertData = groups.map((g) => ({
|
||||
vehicleId,
|
||||
catalogVehicleId: null as string | null,
|
||||
name: g.name,
|
||||
nameOriginal: g.nameOriginal || g.name,
|
||||
parentId: null as string | null,
|
||||
externalId: g.groupId,
|
||||
linkPath: `emex-catalog:${match.vehicleId}:${g.id}`,
|
||||
linkWid: null as string | null,
|
||||
source: "emex" as const,
|
||||
}));
|
||||
dbCategories = await this.db.insert(categories).values(insertData).onConflictDoNothing().returning();
|
||||
this.logger.log(`Stored ${dbCategories.length} EMEX pre-scraped categories for ${vehicle.vin}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`EMEX pre-scraped match failed for ${vehicle.vin}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If pre-scraped match didn't work, try on-demand EMEX scrape
|
||||
if (dbCategories.length === 0) {
|
||||
this.logger.log(`No pre-scraped match for ${vehicle.vin}, trying EMEX on-demand fallback`);
|
||||
try {
|
||||
const emexResult = await this.emexService.decodeVin(vehicle.vin);
|
||||
if (emexResult) {
|
||||
@@ -302,6 +341,7 @@ export class CategoriesService {
|
||||
} catch (emexErr) {
|
||||
this.logger.warn(`EMEX category fallback failed for ${vehicle.vin}: ${(emexErr as Error).message}`);
|
||||
}
|
||||
} // end: if (dbCategories.length === 0) — on-demand fallback
|
||||
}
|
||||
|
||||
// Build tree
|
||||
|
||||
@@ -17,11 +17,16 @@ export const emexCatalogs = pgTable(
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
catalogId: varchar("catalog_id", { length: 100 }).notNull(),
|
||||
code: varchar("code", { length: 50 }),
|
||||
brandName: varchar("brand_name", { length: 100 }).notNull(),
|
||||
description: text("description"),
|
||||
sourceId: integer("source_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex("emex_catalogs_catalog_id_idx").on(table.catalogId)],
|
||||
(table) => [
|
||||
uniqueIndex("emex_catalogs_catalog_id_idx").on(table.catalogId),
|
||||
index("emex_catalogs_code_idx").on(table.code),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── EMEX Vehicle ───────────────────────────────────
|
||||
@@ -34,14 +39,24 @@ export const emexVehicles = pgTable(
|
||||
name: varchar("name", { length: 500 }),
|
||||
modelCode: varchar("model_code", { length: 100 }),
|
||||
engine: varchar("engine", { length: 255 }),
|
||||
engineCode: varchar("engine_code", { length: 100 }),
|
||||
bodyType: varchar("body_type", { length: 100 }),
|
||||
transmission: varchar("transmission", { length: 100 }),
|
||||
driveType: varchar("drive_type", { length: 100 }),
|
||||
fuelType: varchar("fuel_type", { length: 100 }),
|
||||
yearFrom: integer("year_from"),
|
||||
yearTo: integer("year_to"),
|
||||
ssd: text("ssd"),
|
||||
optionsRaw: text("options_raw"),
|
||||
rawData: jsonb("raw_data"),
|
||||
sourceId: integer("source_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("emex_vehicles_vehicle_id_idx").on(table.vehicleId),
|
||||
index("emex_vehicles_catalog_id_idx").on(table.catalogId),
|
||||
index("emex_vehicles_name_idx").on(table.name),
|
||||
index("emex_vehicles_source_id_idx").on(table.sourceId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -67,7 +82,7 @@ export const emexPartGroups = pgTable(
|
||||
"emex_part_groups",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
emexVehicleId: uuid("emex_vehicle_id").references(() => emexVehicles.id, {
|
||||
emexCatalogId: uuid("emex_catalog_id").references(() => emexCatalogs.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
groupId: varchar("group_id", { length: 100 }).notNull(),
|
||||
@@ -75,11 +90,35 @@ export const emexPartGroups = pgTable(
|
||||
nameOriginal: varchar("name_original", { length: 500 }),
|
||||
parentGroupId: varchar("parent_group_id", { length: 100 }),
|
||||
sortOrder: integer("sort_order"),
|
||||
hasParts: boolean("has_parts").default(false),
|
||||
hasChildren: boolean("has_children").default(false),
|
||||
sourceId: integer("source_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("emex_part_groups_vehicle_id_idx").on(table.emexVehicleId),
|
||||
index("emex_part_groups_catalog_id_idx").on(table.emexCatalogId),
|
||||
index("emex_part_groups_group_id_idx").on(table.groupId),
|
||||
uniqueIndex("emex_part_groups_catalog_group_idx").on(table.emexCatalogId, table.groupId),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── EMEX Vehicle-Group Link (junction) ─────────────
|
||||
export const emexVehicleGroupLinks = pgTable(
|
||||
"emex_vehicle_group_links",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
emexVehicleId: uuid("emex_vehicle_id")
|
||||
.references(() => emexVehicles.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
emexGroupId: uuid("emex_group_id")
|
||||
.references(() => emexPartGroups.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
ssd: text("ssd"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("emex_vgl_vehicle_group_idx").on(table.emexVehicleId, table.emexGroupId),
|
||||
index("emex_vgl_group_idx").on(table.emexGroupId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -88,23 +127,26 @@ export const emexParts = pgTable(
|
||||
"emex_parts",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
emexVehicleId: uuid("emex_vehicle_id").references(() => emexVehicles.id, {
|
||||
emexCatalogId: uuid("emex_catalog_id").references(() => emexCatalogs.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
groupId: uuid("group_id").references(() => emexPartGroups.id, { onDelete: "cascade" }),
|
||||
partId: varchar("part_id", { length: 100 }),
|
||||
partNumber: varchar("part_number", { length: 100 }),
|
||||
name: varchar("name", { length: 500 }).notNull(),
|
||||
nameOriginal: varchar("name_original", { length: 500 }),
|
||||
description: text("description"),
|
||||
quantity: integer("quantity"),
|
||||
position: varchar("position", { length: 100 }),
|
||||
oemNumber: varchar("oem_number", { length: 100 }),
|
||||
hotspotIndex: integer("hotspot_index"),
|
||||
rawData: jsonb("raw_data"),
|
||||
sourceId: integer("source_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("emex_parts_vehicle_id_idx").on(table.emexVehicleId),
|
||||
index("emex_parts_catalog_id_idx").on(table.emexCatalogId),
|
||||
index("emex_parts_group_id_idx").on(table.groupId),
|
||||
index("emex_parts_part_number_idx").on(table.partNumber),
|
||||
index("emex_parts_oem_number_idx").on(table.oemNumber),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -124,38 +166,31 @@ export const emexPartNumbers = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// ─── EMEX Vehicle Group ─────────────────────────────
|
||||
export const emexVehicleGroups = pgTable(
|
||||
"emex_vehicle_groups",
|
||||
// ─── EMEX Vehicle-Part Link (junction) ──────────────
|
||||
export const emexVehiclePartLinks = pgTable(
|
||||
"emex_vehicle_part_links",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
catalogId: uuid("catalog_id").references(() => emexCatalogs.id, { onDelete: "cascade" }),
|
||||
groupId: varchar("group_id", { length: 100 }).notNull(),
|
||||
name: varchar("name", { length: 500 }).notNull(),
|
||||
parentGroupId: varchar("parent_group_id", { length: 100 }),
|
||||
emexVehicleId: uuid("emex_vehicle_id")
|
||||
.references(() => emexVehicles.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
emexPartId: uuid("emex_part_id")
|
||||
.references(() => emexParts.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
emexGroupId: uuid("emex_group_id")
|
||||
.references(() => emexPartGroups.id, { onDelete: "cascade" }),
|
||||
quantity: integer("quantity"),
|
||||
position: varchar("position", { length: 100 }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("emex_vehicle_groups_catalog_id_idx").on(table.catalogId),
|
||||
index("emex_vehicle_groups_group_id_idx").on(table.groupId),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── EMEX Vehicle Part ──────────────────────────────
|
||||
export const emexVehicleParts = pgTable(
|
||||
"emex_vehicle_parts",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
emexVehicleId: uuid("emex_vehicle_id").references(() => emexVehicles.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
emexPartId: uuid("emex_part_id").references(() => emexParts.id, { onDelete: "cascade" }),
|
||||
fitmentInfo: text("fitment_info"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("emex_vehicle_parts_vehicle_id_idx").on(table.emexVehicleId),
|
||||
index("emex_vehicle_parts_part_id_idx").on(table.emexPartId),
|
||||
uniqueIndex("emex_vpl_vehicle_part_group_idx").on(
|
||||
table.emexVehicleId,
|
||||
table.emexPartId,
|
||||
table.emexGroupId,
|
||||
),
|
||||
index("emex_vpl_part_idx").on(table.emexPartId),
|
||||
index("emex_vpl_group_idx").on(table.emexGroupId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -165,14 +200,19 @@ export const emexSchemaPics = pgTable(
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
groupId: uuid("group_id").references(() => emexPartGroups.id, { onDelete: "cascade" }),
|
||||
imageUrl: text("image_url").notNull(),
|
||||
imageUrl: text("image_url"),
|
||||
originalUrl: text("original_url"),
|
||||
localPath: varchar("local_path", { length: 500 }),
|
||||
hotspots: jsonb("hotspots").default("[]").notNull(),
|
||||
width: integer("width"),
|
||||
height: integer("height"),
|
||||
sortOrder: integer("sort_order").default(0),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index("emex_schema_pics_group_id_idx").on(table.groupId)],
|
||||
(table) => [
|
||||
index("emex_schema_pics_group_id_idx").on(table.groupId),
|
||||
uniqueIndex("emex_schema_pics_group_path_idx").on(table.groupId, table.localPath),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── EMEX Part Image ────────────────────────────────
|
||||
|
||||
@@ -2,13 +2,13 @@ import { Job } from "bullmq";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import {
|
||||
emexCatalogs,
|
||||
emexVehicles,
|
||||
emexVehicleVins,
|
||||
emexPartGroups,
|
||||
emexParts,
|
||||
emexPartNumbers,
|
||||
emexScrapeSessions,
|
||||
emexVehicleGroupLinks,
|
||||
emexVehiclePartLinks,
|
||||
} from "../../database/schema/emex";
|
||||
// Legacy type — kept inline since emex.types.ts was rewritten for emexdwc.ae integration
|
||||
interface EmexScrapeJobData {
|
||||
@@ -57,7 +57,6 @@ export async function processEmexScrape(
|
||||
|
||||
if (!emexVehicleRecord) {
|
||||
// Vehicle not yet in EMEX tables — placeholder for scraper integration
|
||||
// In production, this would call EmexScraperService.scrapeVehicle(vin)
|
||||
console.log(`[emex-scrape] No cached vehicle for VIN ${vin}, scraper integration pending`);
|
||||
|
||||
if (session) {
|
||||
@@ -78,20 +77,22 @@ export async function processEmexScrape(
|
||||
await job.updateProgress(25);
|
||||
console.log(`[emex-scrape] Vehicle resolved: ${emexVehicleRecord.vehicleId}`);
|
||||
|
||||
// ── Step 2: Fetch categories (part groups) ────────────
|
||||
// ── Step 2: Fetch categories (part groups via junction) ──
|
||||
const categoriesResult = await db
|
||||
.select()
|
||||
.from(emexPartGroups)
|
||||
.where(eq(emexPartGroups.emexVehicleId, emexVehicleRecord.id));
|
||||
.select({ id: emexPartGroups.id })
|
||||
.from(emexVehicleGroupLinks)
|
||||
.innerJoin(emexPartGroups, eq(emexVehicleGroupLinks.emexGroupId, emexPartGroups.id))
|
||||
.where(eq(emexVehicleGroupLinks.emexVehicleId, emexVehicleRecord.id));
|
||||
|
||||
await job.updateProgress(50);
|
||||
console.log(`[emex-scrape] Found ${categoriesResult.length} categories`);
|
||||
|
||||
// ── Step 3: Fetch parts ───────────────────────────────
|
||||
// ── Step 3: Fetch parts (via junction) ──────────────────
|
||||
const partsResult = await db
|
||||
.select()
|
||||
.from(emexParts)
|
||||
.where(eq(emexParts.emexVehicleId, emexVehicleRecord.id));
|
||||
.select({ id: emexParts.id })
|
||||
.from(emexVehiclePartLinks)
|
||||
.innerJoin(emexParts, eq(emexVehiclePartLinks.emexPartId, emexParts.id))
|
||||
.where(eq(emexVehiclePartLinks.emexVehicleId, emexVehicleRecord.id));
|
||||
|
||||
await job.updateProgress(100);
|
||||
console.log(`[emex-scrape] Found ${partsResult.length} parts`);
|
||||
|
||||
@@ -48,8 +48,15 @@
|
||||
"locked": "This brand is not in your plan",
|
||||
"upgradeCta": "Upgrade Plan",
|
||||
"loadingModels": "Loading models...",
|
||||
"tabPl24": "Authorized Service Catalogs",
|
||||
"tabEmex": "Other Catalogs",
|
||||
"categories": "Categories",
|
||||
"noCategories": "No categories found",
|
||||
"parts": "Parts",
|
||||
"noParts": "No parts found",
|
||||
"partNumber": "Part No",
|
||||
"partName": "Part Name",
|
||||
"qty": "Qty",
|
||||
"backToBrands": "Back to Brands",
|
||||
"backToModels": "Back to Models",
|
||||
"backToCategories": "Back to Categories",
|
||||
|
||||
@@ -48,8 +48,15 @@
|
||||
"locked": "Bu marka planınızda yok",
|
||||
"upgradeCta": "Planını Yükselt",
|
||||
"loadingModels": "Modeller yükleniyor...",
|
||||
"tabPl24": "Yetkili Servis Katalogları",
|
||||
"tabEmex": "Diğer Kataloglar",
|
||||
"categories": "Kategoriler",
|
||||
"noCategories": "Kategori bulunamadı",
|
||||
"parts": "Parçalar",
|
||||
"noParts": "Parça bulunamadı",
|
||||
"partNumber": "Parça No",
|
||||
"partName": "Parça Adı",
|
||||
"qty": "Adet",
|
||||
"backToBrands": "Markalara Dön",
|
||||
"backToModels": "Modellere Dön",
|
||||
"backToCategories": "Kategorilere Dön",
|
||||
|
||||
@@ -41,9 +41,12 @@ import { Route as DashboardAdminCopyLogsRouteImport } from "./routes/dashboard/a
|
||||
import { Route as DashboardAdminAnalyticsRouteImport } from "./routes/dashboard/admin/analytics"
|
||||
import { Route as DashboardVehiclesIdIndexRouteImport } from "./routes/dashboard/vehicles_/$id/index"
|
||||
import { Route as DashboardCatalogBrandNameIndexRouteImport } from "./routes/dashboard/catalog_/$brandName/index"
|
||||
import { Route as DashboardCatalogEmexCatalogCodeIndexRouteImport } from "./routes/dashboard/catalog_/emex/$catalogCode/index"
|
||||
import { Route as DashboardCatalogBrandNameModelIdIndexRouteImport } from "./routes/dashboard/catalog_/$brandName_/$modelId/index"
|
||||
import { Route as DashboardVehiclesIdCategoriesCategoryIdRouteImport } from "./routes/dashboard/vehicles_/$id/categories_/$categoryId"
|
||||
import { Route as DashboardCatalogEmexCatalogCodeVehicleIdIndexRouteImport } from "./routes/dashboard/catalog_/emex/$catalogCode_/$vehicleId/index"
|
||||
import { Route as DashboardCatalogBrandNameModelIdCategoriesCategoryIdRouteImport } from "./routes/dashboard/catalog_/$brandName_/$modelId/categories_/$categoryId"
|
||||
import { Route as DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRouteImport } from "./routes/dashboard/catalog_/emex/$catalogCode_/$vehicleId_/groups/$groupId"
|
||||
|
||||
const TermsRoute = TermsRouteImport.update({
|
||||
id: "/terms",
|
||||
@@ -208,6 +211,12 @@ const DashboardCatalogBrandNameIndexRoute =
|
||||
path: "/catalog/$brandName/",
|
||||
getParentRoute: () => DashboardRoute,
|
||||
} as any)
|
||||
const DashboardCatalogEmexCatalogCodeIndexRoute =
|
||||
DashboardCatalogEmexCatalogCodeIndexRouteImport.update({
|
||||
id: "/catalog_/emex/$catalogCode/",
|
||||
path: "/catalog/emex/$catalogCode/",
|
||||
getParentRoute: () => DashboardRoute,
|
||||
} as any)
|
||||
const DashboardCatalogBrandNameModelIdIndexRoute =
|
||||
DashboardCatalogBrandNameModelIdIndexRouteImport.update({
|
||||
id: "/catalog_/$brandName_/$modelId/",
|
||||
@@ -220,12 +229,24 @@ const DashboardVehiclesIdCategoriesCategoryIdRoute =
|
||||
path: "/vehicles/$id/categories/$categoryId",
|
||||
getParentRoute: () => DashboardRoute,
|
||||
} as any)
|
||||
const DashboardCatalogEmexCatalogCodeVehicleIdIndexRoute =
|
||||
DashboardCatalogEmexCatalogCodeVehicleIdIndexRouteImport.update({
|
||||
id: "/catalog_/emex/$catalogCode_/$vehicleId/",
|
||||
path: "/catalog/emex/$catalogCode/$vehicleId/",
|
||||
getParentRoute: () => DashboardRoute,
|
||||
} as any)
|
||||
const DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute =
|
||||
DashboardCatalogBrandNameModelIdCategoriesCategoryIdRouteImport.update({
|
||||
id: "/catalog_/$brandName_/$modelId/categories_/$categoryId",
|
||||
path: "/catalog/$brandName/$modelId/categories/$categoryId",
|
||||
getParentRoute: () => DashboardRoute,
|
||||
} as any)
|
||||
const DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute =
|
||||
DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRouteImport.update({
|
||||
id: "/catalog_/emex/$catalogCode_/$vehicleId_/groups/$groupId",
|
||||
path: "/catalog/emex/$catalogCode/$vehicleId/groups/$groupId",
|
||||
getParentRoute: () => DashboardRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
"/": typeof IndexRoute
|
||||
@@ -261,7 +282,10 @@ export interface FileRoutesByFullPath {
|
||||
"/dashboard/vehicles/$id/": typeof DashboardVehiclesIdIndexRoute
|
||||
"/dashboard/vehicles/$id/categories/$categoryId": typeof DashboardVehiclesIdCategoriesCategoryIdRoute
|
||||
"/dashboard/catalog/$brandName/$modelId/": typeof DashboardCatalogBrandNameModelIdIndexRoute
|
||||
"/dashboard/catalog/emex/$catalogCode/": typeof DashboardCatalogEmexCatalogCodeIndexRoute
|
||||
"/dashboard/catalog/$brandName/$modelId/categories/$categoryId": typeof DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute
|
||||
"/dashboard/catalog/emex/$catalogCode/$vehicleId/": typeof DashboardCatalogEmexCatalogCodeVehicleIdIndexRoute
|
||||
"/dashboard/catalog/emex/$catalogCode/$vehicleId/groups/$groupId": typeof DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
"/": typeof IndexRoute
|
||||
@@ -296,7 +320,10 @@ export interface FileRoutesByTo {
|
||||
"/dashboard/vehicles/$id": typeof DashboardVehiclesIdIndexRoute
|
||||
"/dashboard/vehicles/$id/categories/$categoryId": typeof DashboardVehiclesIdCategoriesCategoryIdRoute
|
||||
"/dashboard/catalog/$brandName/$modelId": typeof DashboardCatalogBrandNameModelIdIndexRoute
|
||||
"/dashboard/catalog/emex/$catalogCode": typeof DashboardCatalogEmexCatalogCodeIndexRoute
|
||||
"/dashboard/catalog/$brandName/$modelId/categories/$categoryId": typeof DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute
|
||||
"/dashboard/catalog/emex/$catalogCode/$vehicleId": typeof DashboardCatalogEmexCatalogCodeVehicleIdIndexRoute
|
||||
"/dashboard/catalog/emex/$catalogCode/$vehicleId/groups/$groupId": typeof DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -334,7 +361,10 @@ export interface FileRoutesById {
|
||||
"/dashboard/vehicles_/$id/": typeof DashboardVehiclesIdIndexRoute
|
||||
"/dashboard/vehicles_/$id/categories_/$categoryId": typeof DashboardVehiclesIdCategoriesCategoryIdRoute
|
||||
"/dashboard/catalog_/$brandName_/$modelId/": typeof DashboardCatalogBrandNameModelIdIndexRoute
|
||||
"/dashboard/catalog_/emex/$catalogCode/": typeof DashboardCatalogEmexCatalogCodeIndexRoute
|
||||
"/dashboard/catalog_/$brandName_/$modelId/categories_/$categoryId": typeof DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute
|
||||
"/dashboard/catalog_/emex/$catalogCode_/$vehicleId/": typeof DashboardCatalogEmexCatalogCodeVehicleIdIndexRoute
|
||||
"/dashboard/catalog_/emex/$catalogCode_/$vehicleId_/groups/$groupId": typeof DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -372,7 +402,10 @@ export interface FileRouteTypes {
|
||||
| "/dashboard/vehicles/$id/"
|
||||
| "/dashboard/vehicles/$id/categories/$categoryId"
|
||||
| "/dashboard/catalog/$brandName/$modelId/"
|
||||
| "/dashboard/catalog/emex/$catalogCode/"
|
||||
| "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
|
||||
| "/dashboard/catalog/emex/$catalogCode/$vehicleId/"
|
||||
| "/dashboard/catalog/emex/$catalogCode/$vehicleId/groups/$groupId"
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| "/"
|
||||
@@ -407,7 +440,10 @@ export interface FileRouteTypes {
|
||||
| "/dashboard/vehicles/$id"
|
||||
| "/dashboard/vehicles/$id/categories/$categoryId"
|
||||
| "/dashboard/catalog/$brandName/$modelId"
|
||||
| "/dashboard/catalog/emex/$catalogCode"
|
||||
| "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
|
||||
| "/dashboard/catalog/emex/$catalogCode/$vehicleId"
|
||||
| "/dashboard/catalog/emex/$catalogCode/$vehicleId/groups/$groupId"
|
||||
id:
|
||||
| "__root__"
|
||||
| "/"
|
||||
@@ -444,7 +480,10 @@ export interface FileRouteTypes {
|
||||
| "/dashboard/vehicles_/$id/"
|
||||
| "/dashboard/vehicles_/$id/categories_/$categoryId"
|
||||
| "/dashboard/catalog_/$brandName_/$modelId/"
|
||||
| "/dashboard/catalog_/emex/$catalogCode/"
|
||||
| "/dashboard/catalog_/$brandName_/$modelId/categories_/$categoryId"
|
||||
| "/dashboard/catalog_/emex/$catalogCode_/$vehicleId/"
|
||||
| "/dashboard/catalog_/emex/$catalogCode_/$vehicleId_/groups/$groupId"
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -688,6 +727,13 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof DashboardCatalogBrandNameIndexRouteImport
|
||||
parentRoute: typeof DashboardRoute
|
||||
}
|
||||
"/dashboard/catalog_/emex/$catalogCode/": {
|
||||
id: "/dashboard/catalog_/emex/$catalogCode/"
|
||||
path: "/catalog/emex/$catalogCode"
|
||||
fullPath: "/dashboard/catalog/emex/$catalogCode/"
|
||||
preLoaderRoute: typeof DashboardCatalogEmexCatalogCodeIndexRouteImport
|
||||
parentRoute: typeof DashboardRoute
|
||||
}
|
||||
"/dashboard/catalog_/$brandName_/$modelId/": {
|
||||
id: "/dashboard/catalog_/$brandName_/$modelId/"
|
||||
path: "/catalog/$brandName/$modelId"
|
||||
@@ -702,6 +748,13 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof DashboardVehiclesIdCategoriesCategoryIdRouteImport
|
||||
parentRoute: typeof DashboardRoute
|
||||
}
|
||||
"/dashboard/catalog_/emex/$catalogCode_/$vehicleId/": {
|
||||
id: "/dashboard/catalog_/emex/$catalogCode_/$vehicleId/"
|
||||
path: "/catalog/emex/$catalogCode/$vehicleId"
|
||||
fullPath: "/dashboard/catalog/emex/$catalogCode/$vehicleId/"
|
||||
preLoaderRoute: typeof DashboardCatalogEmexCatalogCodeVehicleIdIndexRouteImport
|
||||
parentRoute: typeof DashboardRoute
|
||||
}
|
||||
"/dashboard/catalog_/$brandName_/$modelId/categories_/$categoryId": {
|
||||
id: "/dashboard/catalog_/$brandName_/$modelId/categories_/$categoryId"
|
||||
path: "/catalog/$brandName/$modelId/categories/$categoryId"
|
||||
@@ -709,6 +762,13 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof DashboardCatalogBrandNameModelIdCategoriesCategoryIdRouteImport
|
||||
parentRoute: typeof DashboardRoute
|
||||
}
|
||||
"/dashboard/catalog_/emex/$catalogCode_/$vehicleId_/groups/$groupId": {
|
||||
id: "/dashboard/catalog_/emex/$catalogCode_/$vehicleId_/groups/$groupId"
|
||||
path: "/catalog/emex/$catalogCode/$vehicleId/groups/$groupId"
|
||||
fullPath: "/dashboard/catalog/emex/$catalogCode/$vehicleId/groups/$groupId"
|
||||
preLoaderRoute: typeof DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRouteImport
|
||||
parentRoute: typeof DashboardRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -747,7 +807,10 @@ interface DashboardRouteChildren {
|
||||
DashboardVehiclesIdIndexRoute: typeof DashboardVehiclesIdIndexRoute
|
||||
DashboardVehiclesIdCategoriesCategoryIdRoute: typeof DashboardVehiclesIdCategoriesCategoryIdRoute
|
||||
DashboardCatalogBrandNameModelIdIndexRoute: typeof DashboardCatalogBrandNameModelIdIndexRoute
|
||||
DashboardCatalogEmexCatalogCodeIndexRoute: typeof DashboardCatalogEmexCatalogCodeIndexRoute
|
||||
DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute: typeof DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute
|
||||
DashboardCatalogEmexCatalogCodeVehicleIdIndexRoute: typeof DashboardCatalogEmexCatalogCodeVehicleIdIndexRoute
|
||||
DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute: typeof DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute
|
||||
}
|
||||
|
||||
const DashboardRouteChildren: DashboardRouteChildren = {
|
||||
@@ -771,8 +834,14 @@ const DashboardRouteChildren: DashboardRouteChildren = {
|
||||
DashboardVehiclesIdCategoriesCategoryIdRoute,
|
||||
DashboardCatalogBrandNameModelIdIndexRoute:
|
||||
DashboardCatalogBrandNameModelIdIndexRoute,
|
||||
DashboardCatalogEmexCatalogCodeIndexRoute:
|
||||
DashboardCatalogEmexCatalogCodeIndexRoute,
|
||||
DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute:
|
||||
DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute,
|
||||
DashboardCatalogEmexCatalogCodeVehicleIdIndexRoute:
|
||||
DashboardCatalogEmexCatalogCodeVehicleIdIndexRoute,
|
||||
DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute:
|
||||
DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute,
|
||||
}
|
||||
|
||||
const DashboardRouteWithChildren = DashboardRoute._addFileChildren(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { Skeleton, Tabs, TabsContent, TabsList, TabsTrigger } from "@sase/ui";
|
||||
import { Library, Lock } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/catalog/")({
|
||||
component: CatalogBrandsPage,
|
||||
@@ -17,14 +18,29 @@ interface CatalogBrand {
|
||||
hasAccess: boolean;
|
||||
}
|
||||
|
||||
interface EmexBrand {
|
||||
id: string;
|
||||
catalogId: string;
|
||||
code: string | null;
|
||||
brandName: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
function CatalogBrandsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState("pl24");
|
||||
|
||||
const { data: brands, isLoading } = useQuery({
|
||||
queryKey: ["catalog-brands"],
|
||||
queryFn: () => api.get<CatalogBrand[]>("/catalog/brands"),
|
||||
});
|
||||
|
||||
const { data: emexBrands, isLoading: emexLoading } = useQuery({
|
||||
queryKey: ["emex-brands"],
|
||||
queryFn: () => api.get<EmexBrand[]>("/catalog/emex/brands"),
|
||||
enabled: tab === "emex",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -32,29 +48,64 @@ function CatalogBrandsPage() {
|
||||
<p className="text-sm text-muted-foreground">{t("catalog.brands")}</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<Skeleton key={`brand-skel-${i}`} className="h-28 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : !brands || brands.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Library className="mb-4 size-12 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground">{t("catalog.noBrands")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{brands.map((brand) => (
|
||||
<BrandCard key={brand.brandName} brand={brand} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="pl24">{t("catalog.tabPl24")}</TabsTrigger>
|
||||
<TabsTrigger value="emex">{t("catalog.tabEmex")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pl24" className="mt-4">
|
||||
{isLoading ? (
|
||||
<BrandGridSkeleton />
|
||||
) : !brands || brands.length === 0 ? (
|
||||
<EmptyBrands message={t("catalog.noBrands")} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{brands.map((brand) => (
|
||||
<PL24BrandCard key={brand.brandName} brand={brand} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="emex" className="mt-4">
|
||||
{emexLoading ? (
|
||||
<BrandGridSkeleton />
|
||||
) : !emexBrands || emexBrands.length === 0 ? (
|
||||
<EmptyBrands message={t("catalog.noBrands")} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{emexBrands.map((brand) => (
|
||||
<EmexBrandCard key={brand.catalogId} brand={brand} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||
function BrandGridSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<Skeleton key={`brand-skel-${i}`} className="h-28 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyBrands({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Library className="mb-4 size-12 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PL24BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!brand.hasAccess) {
|
||||
@@ -95,3 +146,21 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function EmexBrandCard({ brand }: { brand: EmexBrand }) {
|
||||
return (
|
||||
<Link
|
||||
to="/dashboard/catalog/emex/$catalogCode"
|
||||
params={{ catalogCode: brand.catalogId }}
|
||||
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
||||
>
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-orange-500/10">
|
||||
<Library className="size-5 text-orange-500" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold">{brand.brandName}</p>
|
||||
{brand.description && brand.description !== brand.brandName && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{brand.description}</p>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft, Car } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode/")({
|
||||
component: EmexVehicleListPage,
|
||||
});
|
||||
|
||||
interface EmexVehicle {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
name: string | null;
|
||||
engine: string | null;
|
||||
engineCode: string | null;
|
||||
bodyType: string | null;
|
||||
transmission: string | null;
|
||||
yearFrom: number | null;
|
||||
yearTo: number | null;
|
||||
optionsRaw: string | null;
|
||||
}
|
||||
|
||||
function EmexVehicleListPage() {
|
||||
const { t } = useTranslation();
|
||||
const { catalogCode } = Route.useParams();
|
||||
|
||||
const { data: vehicles, isLoading } = useQuery({
|
||||
queryKey: ["emex-vehicles", catalogCode],
|
||||
queryFn: () => api.get<EmexVehicle[]>(`/catalog/emex/brands/${catalogCode}/vehicles`),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/dashboard/catalog" search={{}}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
{t("catalog.backToBrands")}
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">{decodeURIComponent(catalogCode)}</h1>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={`v-skel-${i}`} className="h-16 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : !vehicles || vehicles.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">{t("catalog.noModels")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{vehicles.map((v) => (
|
||||
<Link
|
||||
key={v.id}
|
||||
to="/dashboard/catalog/emex/$catalogCode/$vehicleId"
|
||||
params={{ catalogCode, vehicleId: v.id }}
|
||||
className="flex items-center gap-3 rounded-lg border border-border bg-card p-3 transition-colors hover:bg-accent"
|
||||
>
|
||||
<Car className="size-5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{v.name || v.vehicleId}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[
|
||||
v.engine,
|
||||
v.yearFrom && v.yearTo ? `${v.yearFrom}-${v.yearTo}` : v.yearFrom,
|
||||
v.optionsRaw,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | ")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft, FolderOpen, ChevronRight } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/dashboard/catalog_/emex/$catalogCode_/$vehicleId/",
|
||||
)({
|
||||
component: EmexGroupListPage,
|
||||
});
|
||||
|
||||
interface EmexGroup {
|
||||
id: string;
|
||||
groupId: string;
|
||||
name: string;
|
||||
nameOriginal: string | null;
|
||||
hasParts: boolean | null;
|
||||
hasChildren: boolean | null;
|
||||
}
|
||||
|
||||
function EmexGroupListPage() {
|
||||
const { t } = useTranslation();
|
||||
const { catalogCode, vehicleId } = Route.useParams();
|
||||
|
||||
const { data: groups, isLoading } = useQuery({
|
||||
queryKey: ["emex-groups", vehicleId],
|
||||
queryFn: () => api.get<EmexGroup[]>(`/catalog/emex/vehicles/${vehicleId}/groups`),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/dashboard/catalog/emex/$catalogCode"
|
||||
params={{ catalogCode }}
|
||||
>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
{t("catalog.backToModels")}
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">{t("catalog.categories")}</h1>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<Skeleton key={`g-skel-${i}`} className="h-14 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : !groups || groups.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
{t("catalog.noCategories")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{groups.map((g) => (
|
||||
<Link
|
||||
key={g.id}
|
||||
to="/dashboard/catalog/emex/$catalogCode/$vehicleId/groups/$groupId"
|
||||
params={{ catalogCode, vehicleId, groupId: g.id }}
|
||||
className="flex items-center gap-3 rounded-lg border border-border bg-card p-3 transition-colors hover:bg-accent"
|
||||
>
|
||||
<FolderOpen className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{g.name}</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft, Copy, Check, ImageOff } from "lucide-react";
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/dashboard/catalog_/emex/$catalogCode_/$vehicleId_/groups/$groupId",
|
||||
)({
|
||||
component: EmexGroupPartsPage,
|
||||
});
|
||||
|
||||
interface EmexPart {
|
||||
id: string;
|
||||
partNumber: string | null;
|
||||
name: string;
|
||||
nameOriginal: string | null;
|
||||
description: string | null;
|
||||
oemNumber: string | null;
|
||||
hotspotIndex: number | null;
|
||||
quantity: number | null;
|
||||
position: string | null;
|
||||
}
|
||||
|
||||
interface EmexSchemaPic {
|
||||
id: string;
|
||||
imageUrl: string | null;
|
||||
localPath: string | null;
|
||||
hotspots: unknown;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
sortOrder: number | null;
|
||||
}
|
||||
|
||||
interface EmexGroupParts {
|
||||
group: {
|
||||
id: string;
|
||||
groupId: string;
|
||||
name: string;
|
||||
nameOriginal: string | null;
|
||||
};
|
||||
parts: EmexPart[];
|
||||
schemaPics: EmexSchemaPic[];
|
||||
}
|
||||
|
||||
function EmexGroupPartsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { catalogCode, vehicleId, groupId } = Route.useParams();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["emex-group-parts", vehicleId, groupId],
|
||||
queryFn: () =>
|
||||
api.get<EmexGroupParts>(`/catalog/emex/vehicles/${vehicleId}/groups/${groupId}`),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/dashboard/catalog/emex/$catalogCode/$vehicleId"
|
||||
params={{ catalogCode, vehicleId }}
|
||||
>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
{t("catalog.backToCategories")}
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">{data?.group?.name || t("catalog.parts")}</h1>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-64 w-full rounded-lg" />
|
||||
<Skeleton className="h-96 w-full rounded-lg" />
|
||||
</div>
|
||||
) : !data ? (
|
||||
<p className="py-8 text-center text-muted-foreground">{t("catalog.noParts")}</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Schema images */}
|
||||
{data.schemaPics.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{data.schemaPics.map((pic) => (
|
||||
<div
|
||||
key={pic.id}
|
||||
className="overflow-hidden rounded-lg border border-border bg-card"
|
||||
>
|
||||
{pic.imageUrl ? (
|
||||
<img
|
||||
src={pic.imageUrl}
|
||||
alt={data.group?.name || "Schema"}
|
||||
className="w-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-48 items-center justify-center bg-muted">
|
||||
<ImageOff className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parts table */}
|
||||
{data.parts.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">#</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("catalog.partNumber")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("catalog.partName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">OEM</th>
|
||||
<th className="px-3 py-2 text-center font-medium">
|
||||
{t("catalog.qty")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{data.parts.map((part, idx) => (
|
||||
<PartRow key={part.id} part={part} idx={idx} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-4 text-center text-muted-foreground">{t("catalog.noParts")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PartRow({ part, idx }: { part: EmexPart; idx: number }) {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const copyOem = useCallback((code: string) => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(code);
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
}, []);
|
||||
|
||||
const oemCode = part.partNumber || part.oemNumber;
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-muted/30">
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{part.hotspotIndex ?? idx + 1}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">
|
||||
{oemCode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyOem(oemCode)}
|
||||
className="inline-flex items-center gap-1 rounded px-1 py-0.5 hover:bg-accent"
|
||||
>
|
||||
{oemCode}
|
||||
{copied === oemCode ? (
|
||||
<Check className="size-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">{part.name}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
{part.oemNumber && part.oemNumber !== part.partNumber ? part.oemNumber : ""}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{part.quantity ?? 1}</td>
|
||||
<td className="px-3 py-2 text-center text-xs text-muted-foreground">
|
||||
{part.position}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user