feat: EMEX catalog integration — schema, data migration, backend & frontend
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:
2026-03-06 04:06:29 +00:00
parent 63f6309bf3
commit 1e8a049219
17 changed files with 1351 additions and 75 deletions

View File

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

View 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);
}
}

View 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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`);

View File

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

View File

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

View File

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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}