Merge dev into main: pcat catalog + EMEX VIN decode + autonomy charter
Some checks failed
Deploy / Deploy to Production (push) Has been cancelled

Brings 35 dev branch commits onto main, integrating with the 32
main-side commits (Sentry, translation pipeline, deploy fixes).
Critical merge across VIN decode pipeline and catalog services.

# Conflicts:
#	.gitignore
#	apps/api/src/catalog/catalog.module.ts
#	apps/api/src/categories/categories.module.ts
#	apps/api/src/categories/categories.service.spec.ts
#	apps/api/src/common/filters/http-exception.filter.ts
#	apps/api/src/integrations/emex/emex.service.ts
#	apps/api/src/integrations/emex/emex.types.ts
#	apps/api/src/jobs/processors/emex-scrape.processor.ts
#	apps/api/src/vehicles/vehicles.controller.ts
#	apps/api/src/vehicles/vehicles.module.ts
#	apps/api/src/vehicles/vehicles.service.ts
#	apps/web/src/routes/dashboard.tsx
#	apps/web/src/routes/dashboard/catalog/index.tsx
This commit is contained in:
Semih
2026-05-10 23:57:09 +00:00
49 changed files with 5339 additions and 67 deletions

View File

@@ -59,6 +59,7 @@
"postgres": "^3.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"undici": "^7.22.0",
"zod": "^3.24.0"
},
"devDependencies": {
@@ -73,7 +74,6 @@
"playwright": "^1.50.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"undici": "^7.22.0",
"vitest": "^3.0.0"
}
}

View File

@@ -4,11 +4,15 @@ import { StorageModule } from "../storage/storage.module";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
import { CatalogController } from "./catalog.controller";
import { CatalogService } from "./catalog.service";
import { EmexCatalogController } from "./emex-catalog.controller";
import { EmexCatalogService } from "./emex-catalog.service";
import { PcatCatalogController } from "./pcat-catalog.controller";
import { PcatCatalogService } from "./pcat-catalog.service";
@Module({
imports: [PL24Module, SubscriptionsModule, StorageModule],
controllers: [CatalogController],
providers: [CatalogService],
exports: [CatalogService],
controllers: [CatalogController, EmexCatalogController, PcatCatalogController],
providers: [CatalogService, EmexCatalogService, PcatCatalogService],
exports: [CatalogService, EmexCatalogService, PcatCatalogService],
})
export class CatalogModule {}

View File

@@ -0,0 +1,51 @@
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("brands/:code/wizard")
getWizard(@Param("code") code: string, @Query("ssd") ssd?: string) {
return this.emexCatalogService.getWizard(code, ssd || "");
}
@Get("brands/:code/wizard-vehicles")
getWizardVehicles(
@Param("code") code: string,
@Query("name") name: string,
@Query("model") model?: string,
) {
return this.emexCatalogService.getWizardVehicles(code, name, model);
}
@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,696 @@
import { Inject, Injectable, Logger, ServiceUnavailableException } from "@nestjs/common";
import { and, eq, ilike, or, sql } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import {
emexCatalogs,
emexPartGroups,
emexPartNumbers,
emexParts,
emexSchemaPics,
emexVehicleGroupLinks,
emexVehiclePartLinks,
emexVehicleVins,
emexVehicles,
} 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;
name: string;
oemCode: string;
quantity: number;
position: string | null;
hotspotIndex: number | null;
}
interface EmexSchemaPicDto {
id: string;
url: string;
width: number;
height: number;
label: string;
}
export interface EmexGroupPartsDto {
group: EmexGroupDto;
parts: EmexPartDto[];
schemaPics: EmexSchemaPicDto[];
}
export interface EmexVehicleMatch {
vehicleId: string;
catalogCode: string;
vehicleName: string | null;
candidates: EmexVehicleDto[];
/** When DB has no match but wizard found the model, provides a QuickGroups URL for on-demand category fetch */
wizardQuickGroupsUrl?: string | null;
/** Brand name from emex_catalogs (populated by lookupVinCache) */
brandName?: string | null;
}
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);
// Transform to SchemaViewer-compatible format
const formattedParts: EmexPartDto[] = parts.map((p) => ({
id: p.id,
name: p.name,
oemCode: p.partNumber || p.oemNumber || "",
quantity: p.quantity ?? 1,
position: p.position,
hotspotIndex: p.position ? Number.parseInt(p.position, 10) || null : null,
}));
const formattedPics: EmexSchemaPicDto[] = schemaPics.map((sp) => ({
id: sp.id,
url: sp.imageUrl || "",
width: sp.width ?? 0,
height: sp.height ?? 0,
label: group.name || "",
}));
const result: EmexGroupPartsDto = { group, parts: formattedParts, schemaPics: formattedPics };
await this.redis.setJson(cacheKey, result, CACHE_TTL.parts);
return result;
}
// ── Wizard (proxy emexdwc.ae GetWizard2) ─────────────
private static readonly EMEX_BASE = "https://emexdwc.ae";
private static readonly EMEX_HDR: Record<string, string> = {
Accept: "application/json",
Referer: "https://emexdwc.ae/CatalogParamSearch.aspx",
"X-Requested-With": "XMLHttpRequest",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
};
async getWizard(catalogCode: string, ssd: string): Promise<unknown> {
const cacheKey = `emex:wizard:${catalogCode}:${ssd || "_root"}`;
const cached = await this.redis.getJson(cacheKey);
if (cached) return cached;
const params = new URLSearchParams({
catalogCode,
ssd,
_tstamp: String(Date.now()),
});
const url = `${EmexCatalogService.EMEX_BASE}/api/Catalog.svc/GetWizard2?${params}`;
try {
const res = await fetch(url, {
headers: EmexCatalogService.EMEX_HDR,
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
throw new Error(`EMEX wizard HTTP ${res.status}`);
}
const data = await res.json();
await this.redis.setJson(cacheKey, data, 3600); // 1h cache
return data;
} catch (err) {
this.logger.error(`getWizard failed: ${(err as Error).message}`);
throw new ServiceUnavailableException("EMEX wizard servisi kulanilamiyor");
}
}
/**
* Find vehicles in our DB that match a wizard selection.
* Tries: exact name match → name prefix → model-based fuzzy match.
* `name` is the "Sales Designation" from the wizard.
* `model` is the "Model" parameter from the wizard (optional).
*/
async getWizardVehicles(
catalogCode: string,
name: string,
model?: string,
): Promise<EmexVehicleDto[]> {
const hashInput = `${name}|${model || ""}`;
const cacheKey = `emex:wv:${catalogCode}:${Buffer.from(hashInput).toString("base64url").slice(0, 40)}`;
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) return [];
const vehicleCols = {
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,
};
// 1. Exact match on Sales Designation name
let rows = await this.db
.select(vehicleCols)
.from(emexVehicles)
.where(and(eq(emexVehicles.catalogId, catalog.id), eq(emexVehicles.name, name)))
.orderBy(emexVehicles.optionsRaw);
// 2. Prefix match on name
if (rows.length === 0) {
rows = await this.db
.select(vehicleCols)
.from(emexVehicles)
.where(and(eq(emexVehicles.catalogId, catalog.id), ilike(emexVehicles.name, `${name}%`)))
.orderBy(emexVehicles.optionsRaw)
.limit(100);
}
// 3. Model-based fuzzy match: extract short prefix from model name
// e.g. model="A3 Cabriolet" → search for names containing "A3 Cab"
if (rows.length === 0 && model) {
// Build search patterns from model name
// "A3 Cabriolet" → try "A3 Cab", "A3 Cabrio", "A3"
const words = model.split(/[\s/]+/);
const patterns: string[] = [];
if (words.length >= 2) {
patterns.push(`%${words[0]} ${words[1].slice(0, 3)}%`);
patterns.push(`%${words[0]} ${words[1].slice(0, 6)}%`);
}
patterns.push(`%${words[0]}%`);
for (const pattern of patterns) {
rows = await this.db
.select(vehicleCols)
.from(emexVehicles)
.where(and(eq(emexVehicles.catalogId, catalog.id), ilike(emexVehicles.name, pattern)))
.orderBy(emexVehicles.optionsRaw)
.limit(100);
if (rows.length > 0) break;
}
}
if (rows.length > 0) {
await this.redis.setJson(cacheKey, rows, CACHE_TTL.vehicles);
}
return rows;
}
/**
* Match a VIN-decoded vehicle to DB using wizard API flow.
* VIN-decode SSDs are too specific for GetWizard2 (returns HTML instead of JSON).
* So we start from root SSD ("") and walk wizard steps until we find a model
* option matching pathData (e.g. "Focus CB4 2008-2011"), then use getWizardVehicles
* to match motor variants against DB.
*/
async matchBySsd(
catalogCode: string,
_ssd: string,
pathData?: string,
): Promise<EmexVehicleMatch | null> {
const pathName = pathData?.replace(/^Name:\s*/i, "").trim();
if (!pathName) {
this.logger.log("matchBySsd: no pathData name, skipping");
return null;
}
const cacheKey = `emex:matchssd:${catalogCode}:${Buffer.from(pathName).toString("base64url").slice(0, 32)}`;
const cached = await this.redis.getJson<EmexVehicleMatch>(cacheKey);
if (cached) return cached;
try {
// Walk wizard from root until we find pathName in model options
let currentSsd = "";
const maxSteps = 5;
for (let i = 0; i < maxSteps; i++) {
let wizardData: any;
try {
wizardData = await this.getWizard(catalogCode, currentSsd);
} catch {
this.logger.warn(`matchBySsd: wizard call failed at step ${i}`);
return null;
}
const steps = Array.isArray(wizardData) ? wizardData : [];
if (steps.length === 0) break;
let found = false;
let advanced = false;
for (const step of steps) {
const options = step.options as Array<{ key: string; value: string }> | undefined;
if (!options?.length) continue;
// Look for pathName in this step's options
const exact = options.find((o) => o.value === pathName);
if (exact) {
this.logger.log(`matchBySsd: found "${pathName}" in wizard step "${step.name}"`);
// Use getWizardVehicles which does DB matching with model name
const dbVehicles = await this.getWizardVehicles(catalogCode, pathName);
if (dbVehicles.length > 0) {
const match: EmexVehicleMatch = {
vehicleId: dbVehicles[0].id,
catalogCode,
vehicleName: pathName,
candidates: dbVehicles,
};
await this.redis.setJson(cacheKey, match, CACHE_TTL.match);
this.logger.log(
`matchBySsd: DB match via wizard — "${pathName}" → ${dbVehicles.length} candidates`,
);
return match;
}
// pathName found in wizard but no DB match — still no categories available
this.logger.log(`matchBySsd: "${pathName}" found in wizard but no DB match`);
return null;
}
// Not found in this step — if step is undetermined, try advancing
// Pick the first option that's likely correct (e.g. "Europe", "Passenger")
if (!step.determined && !advanced) {
// Heuristic: pick common values for region/vehicle type
const preferred = ["Europe", "Passenger"];
const pick = options.find((o) => preferred.includes(o.value)) || options[0];
currentSsd = pick.key;
advanced = true;
this.logger.log(`matchBySsd: advancing wizard "${step.name}" → "${pick.value}"`);
}
found = found || options.length > 0;
}
if (!advanced) break; // All steps determined, nowhere to advance
}
this.logger.log(`matchBySsd: "${pathName}" not found in wizard for c=${catalogCode}`);
return null;
} catch (err) {
this.logger.warn(`matchBySsd failed: ${(err as Error).message}`);
return null;
}
}
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;
}
// ── VIN Cache ──────────────────────────────────────
/**
* Look up a VIN in the emex_vehicle_vins cache table.
* Returns the matched EmexVehicleMatch if found, null otherwise.
*/
async lookupVinCache(vin: string): Promise<EmexVehicleMatch | null> {
const cacheKey = `emex:vincache:${vin}`;
const cached = await this.redis.getJson<EmexVehicleMatch>(cacheKey);
if (cached) return cached;
const [row] = await this.db
.select({
emexVehicleId: emexVehicleVins.emexVehicleId,
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,
catalogId: emexCatalogs.catalogId,
brandName: emexCatalogs.brandName,
})
.from(emexVehicleVins)
.innerJoin(emexVehicles, eq(emexVehicleVins.emexVehicleId, emexVehicles.id))
.innerJoin(emexCatalogs, eq(emexVehicles.catalogId, emexCatalogs.id))
.where(eq(emexVehicleVins.vin, vin))
.limit(1);
if (!row) return null;
const candidate: EmexVehicleDto = {
id: row.emexVehicleId!,
vehicleId: row.vehicleId,
name: row.name,
engine: row.engine,
engineCode: row.engineCode,
bodyType: row.bodyType,
transmission: row.transmission,
driveType: row.driveType,
fuelType: row.fuelType,
yearFrom: row.yearFrom,
yearTo: row.yearTo,
optionsRaw: row.optionsRaw,
};
const match: EmexVehicleMatch = {
vehicleId: row.emexVehicleId!,
catalogCode: row.catalogId,
vehicleName: row.name,
candidates: [candidate],
brandName: row.brandName,
};
await this.redis.setJson(cacheKey, match, 86400); // 24h
return match;
}
/**
* Save a VIN → emex_vehicle mapping to the cache table.
* Uses ON CONFLICT DO NOTHING to handle concurrent inserts.
*/
async saveVinCache(vin: string, emexVehicleId: string): Promise<void> {
try {
await this.db.insert(emexVehicleVins).values({ vin, emexVehicleId }).onConflictDoNothing();
this.logger.log(`VIN cache saved: ${vin}${emexVehicleId}`);
// Invalidate Redis cache so next lookup picks up the DB row
await this.redis.del(`emex:vincache:${vin}`);
} catch (err) {
this.logger.warn(`VIN cache save failed: ${(err as Error).message}`);
}
}
}

View File

@@ -0,0 +1,42 @@
import { Controller, Get, Param, Query } from "@nestjs/common";
import { PcatCatalogService } from "./pcat-catalog.service";
@Controller("catalog/pcat")
export class PcatCatalogController {
constructor(private pcatCatalogService: PcatCatalogService) {}
@Get("catalogs")
getCatalogs() {
return this.pcatCatalogService.getCatalogs();
}
@Get("catalogs/:catalogId/models")
getModels(@Param("catalogId") catalogId: string) {
return this.pcatCatalogService.getModels(catalogId);
}
@Get("catalogs/:catalogId/groups")
getGroups(@Param("catalogId") catalogId: string, @Query("parentId") parentId?: string) {
return this.pcatCatalogService.getCarGroups("", parentId);
}
@Get("catalogs/:catalogId/models/:modelId/cars")
getCars(@Param("modelId") modelId: string) {
return this.pcatCatalogService.getCars(modelId);
}
@Get("cars/:carId/groups")
getCarGroups(@Param("carId") carId: string, @Query("parentId") parentId?: string) {
return this.pcatCatalogService.getCarGroups(carId, parentId);
}
@Get("cars/:carId/groups/:groupId/schemas")
getSchemaImages(@Param("carId") carId: string, @Param("groupId") groupId: string) {
return this.pcatCatalogService.getSchemaImages(carId, groupId);
}
@Get("schemas/:schemaImageId")
getSchemaDetail(@Param("schemaImageId") schemaImageId: string) {
return this.pcatCatalogService.getSchemaDetail(schemaImageId);
}
}

View File

@@ -0,0 +1,404 @@
import { Inject, Injectable, Logger } from "@nestjs/common";
import { sql } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import { RedisService } from "../redis/redis.service";
export interface PcatCatalogDto {
id: string;
name: string;
brand: string | null;
imgUrl: string | null;
modelsCount: number;
carsCount: number;
}
export interface PcatModelDto {
id: string;
catalogId: string;
name: string;
imgUrl: string | null;
yearFrom: number | null;
yearTo: number | null;
carsCount: number;
}
export interface PcatCarDto {
id: string;
modelId: string;
name: string;
yearFrom: number | null;
yearTo: number | null;
engine: string | null;
transmission: string | null;
bodyType: string | null;
fuelType: string | null;
driveType: string | null;
steering: string | null;
schemasCount: number;
partsCount: number;
}
export interface PcatGroupDto {
id: string;
catalogId: string;
parentId: string | null;
name: string;
imgUrl: string | null;
hasSubgroups: boolean;
hasParts: boolean;
}
export interface PcatSchemaImageDto {
id: string;
name: string | null;
imgUrl: string | null;
partsCount: number;
}
export interface PcatSchemaDetailDto {
schemaImage: PcatSchemaImageDto;
parts: {
id: string;
name: string;
oemCode: string;
quantity: number;
position: string | null;
hotspotIndex: number | null;
}[];
schemaPics: { id: string; url: string; width: number; height: number; label: string }[];
hotspots: {
id: string;
key: string;
group: number;
shape: "rect";
coordinates: number[];
label: string;
}[];
}
const CACHE_PREFIX = "pcat2"; // bumped to invalidate stale cache from v1 queries
const CACHE_TTL = {
catalogs: 86400,
models: 43200,
cars: 21600,
groups: 21600,
schemas: 7200,
schemaDetail: 7200,
};
@Injectable()
export class PcatCatalogService {
private readonly logger = new Logger(PcatCatalogService.name);
constructor(
@Inject(DATABASE) private db: Database,
private redis: RedisService,
) {}
async getCatalogs(): Promise<PcatCatalogDto[]> {
const cacheKey = `${CACHE_PREFIX}:catalogs`;
const cached = await this.redis.getJson<PcatCatalogDto[]>(cacheKey);
if (cached) return cached;
const rows = await this.db.execute<{
id: string;
name: string;
brand: string | null;
img_url: string | null;
models_count: number;
cars_count: number;
}>(
sql`SELECT id, name, brand, img_url, models_count, cars_count FROM pc.catalogs WHERE is_active = true ORDER BY name`,
);
const result: PcatCatalogDto[] = rows.map((r) => ({
id: r.id,
name: r.name,
brand: r.brand,
imgUrl: r.img_url,
modelsCount: r.models_count ?? 0,
carsCount: r.cars_count ?? 0,
}));
await this.redis.setJson(cacheKey, result, CACHE_TTL.catalogs);
return result;
}
async getModels(catalogId: string): Promise<PcatModelDto[]> {
const cacheKey = `${CACHE_PREFIX}:models:${catalogId}`;
const cached = await this.redis.getJson<PcatModelDto[]>(cacheKey);
if (cached) return cached;
const rows = await this.db.execute<{
id: string;
catalog_id: string;
name: string;
img_url: string | null;
year_from: number | null;
year_to: number | null;
cars_count: number;
}>(
sql`SELECT id, catalog_id, name, img_url, year_from, year_to, cars_count FROM pc.models WHERE catalog_id = ${catalogId} AND is_active = true ORDER BY name`,
);
const result: PcatModelDto[] = rows.map((r) => ({
id: r.id,
catalogId: r.catalog_id,
name: r.name,
imgUrl: r.img_url,
yearFrom: r.year_from,
yearTo: r.year_to,
carsCount: r.cars_count ?? 0,
}));
await this.redis.setJson(cacheKey, result, CACHE_TTL.models);
return result;
}
async getCars(modelId: string): Promise<PcatCarDto[]> {
const cacheKey = `${CACHE_PREFIX}:cars:${modelId}`;
const cached = await this.redis.getJson<PcatCarDto[]>(cacheKey);
if (cached) return cached;
const rows = await this.db.execute<{
id: string;
model_id: string;
name: string;
year_from: number | null;
year_to: number | null;
engine: string | null;
transmission: string | null;
body_type: string | null;
fuel_type: string | null;
drive_type: string | null;
steering: string | null;
schemas_count: number;
parts_count: number;
}>(
sql`SELECT id, model_id, name, year_from, year_to, engine, transmission, body_type, fuel_type, drive_type, steering, schemas_count, parts_count
FROM pc.cars WHERE model_id = ${modelId} AND is_active = true
ORDER BY year_from DESC NULLS LAST, name`,
);
const result: PcatCarDto[] = rows.map((r) => ({
id: r.id,
modelId: r.model_id,
name: r.name,
yearFrom: r.year_from,
yearTo: r.year_to,
engine: r.engine,
transmission: r.transmission,
bodyType: r.body_type,
fuelType: r.fuel_type,
driveType: r.drive_type,
steering: r.steering,
schemasCount: r.schemas_count ?? 0,
partsCount: r.parts_count ?? 0,
}));
await this.redis.setJson(cacheKey, result, CACHE_TTL.cars);
return result;
}
async getCarGroups(carId: string, parentId?: string): Promise<PcatGroupDto[]> {
const cacheKey = `${CACHE_PREFIX}:car-groups:${carId}:${parentId || "root"}`;
const cached = await this.redis.getJson<PcatGroupDto[]>(cacheKey);
if (cached) return cached;
const rows = parentId
? await this.db.execute<{
id: string;
catalog_id: string;
parent_id: string | null;
name: string;
img_url: string | null;
has_subgroups: boolean;
has_parts: boolean;
}>(
sql`WITH car_cats AS (
SELECT DISTINCT category_id FROM pc.schema_images WHERE car_id = ${carId}
)
SELECT g.id, g.catalog_id, g.parent_id, g.name, g.img_url,
EXISTS(SELECT 1 FROM car_cats cc JOIN pc.groups g2 ON g2.id = cc.category_id WHERE g2.parent_id = g.id) as has_subgroups,
true as has_parts
FROM car_cats cc
JOIN pc.groups g ON g.id = cc.category_id
WHERE g.parent_id = ${parentId}
ORDER BY g.name`,
)
: await this.db.execute<{
id: string;
catalog_id: string;
parent_id: string | null;
name: string;
img_url: string | null;
has_subgroups: boolean;
has_parts: boolean;
}>(
sql`WITH car_cats AS (
SELECT DISTINCT category_id FROM pc.schema_images WHERE car_id = ${carId}
)
SELECT g.id, g.catalog_id, g.parent_id, g.name, g.img_url,
EXISTS(SELECT 1 FROM car_cats cc JOIN pc.groups g2 ON g2.id = cc.category_id WHERE g2.parent_id = g.id) as has_subgroups,
true as has_parts
FROM car_cats cc
JOIN pc.groups g ON g.id = cc.category_id
WHERE g.parent_id IS NULL
ORDER BY g.name`,
);
const result: PcatGroupDto[] = rows.map((r) => ({
id: r.id,
catalogId: r.catalog_id,
parentId: r.parent_id,
name: r.name,
imgUrl: r.img_url,
hasSubgroups: r.has_subgroups ?? false,
hasParts: r.has_parts ?? false,
}));
await this.redis.setJson(cacheKey, result, CACHE_TTL.groups);
return result;
}
async getSchemaImages(carId: string, groupId: string): Promise<PcatSchemaImageDto[]> {
const cacheKey = `${CACHE_PREFIX}:schemas:${carId}:${groupId}`;
const cached = await this.redis.getJson<PcatSchemaImageDto[]>(cacheKey);
if (cached) return cached;
const rows = await this.db.execute<{
id: number;
name: string | null;
img_url: string | null;
parts_count: number;
}>(
sql`SELECT id, name, img_url, parts_count FROM pc.schema_images
WHERE car_id = ${carId} AND category_id = ${groupId} AND is_active = true
ORDER BY name`,
);
const result: PcatSchemaImageDto[] = rows.map((r) => ({
id: String(r.id),
name: r.name,
imgUrl: r.img_url,
partsCount: r.parts_count ?? 0,
}));
await this.redis.setJson(cacheKey, result, CACHE_TTL.schemas);
return result;
}
async getSchemaDetail(schemaImageId: string): Promise<PcatSchemaDetailDto> {
const cacheKey = `${CACHE_PREFIX}:schema-detail:${schemaImageId}`;
const cached = await this.redis.getJson<PcatSchemaDetailDto>(cacheKey);
if (cached) return cached;
const numId = Number.parseInt(schemaImageId, 10);
// Get schema image info
const [image] = await this.db.execute<{
id: number;
name: string | null;
img_url: string | null;
parts_count: number;
}>(
sql`SELECT id, name, img_url, parts_count FROM pc.schema_images WHERE id = ${numId} LIMIT 1`,
);
if (!image) {
return {
schemaImage: { id: schemaImageId, name: null, imgUrl: null, partsCount: 0 },
parts: [],
schemaPics: [],
hotspots: [],
};
}
// Get parts via schema_parts junction
const partRows = await this.db.execute<{
sp_id: number;
position_number: string | null;
quantity: number;
sp_description: string | null;
sp_notice: string | null;
part_id: number;
part_number: string;
part_name: string | null;
}>(
sql`SELECT sp.id as sp_id, sp.position_number, sp.quantity, sp.description as sp_description, sp.notice as sp_notice,
p.id as part_id, p.part_number, p.name as part_name
FROM pc.schema_parts sp JOIN pc.parts p ON p.id = sp.part_id
WHERE sp.schema_image_id = ${numId}
ORDER BY sp.position_number, p.name`,
);
// Get hotspots
const hotspotRows = await this.db.execute<{
id: number;
position_number: string;
x: number;
y: number;
width: number;
height: number;
}>(
sql`SELECT id, position_number, x, y, width, height FROM pc.part_hotspots
WHERE schema_image_id = ${numId} ORDER BY position_number`,
);
// Calculate image dimensions from hotspot bounds
let imgWidth = 0;
let imgHeight = 0;
for (const h of hotspotRows) {
imgWidth = Math.max(imgWidth, h.x + h.width);
imgHeight = Math.max(imgHeight, h.y + h.height);
}
// Add 10% padding
imgWidth = Math.round(imgWidth * 1.1) || 1000;
imgHeight = Math.round(imgHeight * 1.1) || 800;
const imgUrl = image.img_url?.startsWith("//") ? `https:${image.img_url}` : image.img_url || "";
const schemaImage: PcatSchemaImageDto = {
id: String(image.id),
name: image.name,
imgUrl: image.img_url,
partsCount: image.parts_count ?? 0,
};
const parts = partRows.map((r) => {
const pos = r.position_number ? Number.parseInt(r.position_number, 10) : null;
return {
id: String(r.sp_id),
name: r.part_name || r.sp_description || "",
oemCode: r.part_number,
quantity: r.quantity ?? 1,
position: r.position_number,
hotspotIndex: Number.isNaN(pos!) ? null : pos,
};
});
const schemaPics = [
{
id: String(image.id),
url: imgUrl,
width: imgWidth,
height: imgHeight,
label: image.name || "",
},
];
const hotspots = hotspotRows.map((h) => ({
id: String(h.id),
key: `hotspot-${h.id}`,
group: Number.parseInt(h.position_number, 10) || 0,
shape: "rect" as const,
coordinates: [h.x, h.y, h.width, h.height],
label: h.position_number,
}));
const result: PcatSchemaDetailDto = { schemaImage, parts, schemaPics, hotspots };
await this.redis.setJson(cacheKey, result, CACHE_TTL.schemaDetail);
return result;
}
}

View File

@@ -1,4 +1,5 @@
import { Module } from "@nestjs/common";
import { CatalogModule } from "../catalog/catalog.module";
import { EmexModule } from "../integrations/emex/emex.module";
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
import { PL24Module } from "../integrations/pl24/pl24.module";
@@ -7,7 +8,7 @@ import { CategoriesController } from "./categories.controller";
import { CategoriesService } from "./categories.service";
@Module({
imports: [PL24Module, EmexModule, PartsCatalogsModule, TranslationsModule],
imports: [PL24Module, EmexModule, PartsCatalogsModule, CatalogModule, TranslationsModule],
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],

View File

@@ -1,3 +1,4 @@
import { join } from "node:path";
import {
type ArgumentsHost,
Catch,
@@ -6,7 +7,6 @@ import {
HttpStatus,
Logger,
} from "@nestjs/common";
import { join } from "node:path";
import { SpanStatusCode, trace } from "@opentelemetry/api";
import { SentryExceptionCaptured } from "@sentry/nestjs";
import { Request, Response } from "express";
@@ -43,7 +43,11 @@ export class HttpExceptionFilter implements ExceptionFilter {
}
// SPA fallback: serve index.html for non-API GET 404s
if (status === HttpStatus.NOT_FOUND && request.method === "GET" && !request.path.startsWith("/api")) {
if (
status === HttpStatus.NOT_FOUND &&
request.method === "GET" &&
!request.path.startsWith("/api")
) {
return response.sendFile(this.indexPath);
}

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),
],
);
@@ -57,7 +72,7 @@ export const emexVehicleVins = pgTable(
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("emex_vehicle_vins_vin_idx").on(table.vin),
uniqueIndex("emex_vehicle_vins_vin_idx").on(table.vin),
index("emex_vehicle_vins_vehicle_id_idx").on(table.emexVehicleId),
],
);
@@ -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,30 @@ 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 +199,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

@@ -131,6 +131,9 @@ export interface EmexScraperResponse {
catalogCode: string;
ssd?: string;
vehicle: EmexVehicleData;
vehicleLabel?: string;
vid?: string;
pathData?: string;
message?: string;
error?: string;
rawResponse?: Record<string, unknown>;
@@ -231,33 +234,103 @@ export interface CatalogEntry {
* WMI (World Manufacturer Identifier) to catalog mapping
*/
export const CATALOG_MAP: Record<string, CatalogEntry> = {
// BMW
WBA: { code: "BMW202501", brand: "BMW" },
WBS: { code: "BMW202501", brand: "BMW" },
WBY: { code: "BMW202501", brand: "BMW" },
// Mercedes-Benz
WDB: { code: "MB201810", brand: "Mercedes-Benz" },
WDD: { code: "MB201810", brand: "Mercedes-Benz" },
WDC: { code: "MB201810", brand: "Mercedes-Benz" },
WDF: { code: "MB201810", brand: "Mercedes-Benz" },
// Audi
WAU: { code: "AU1587", brand: "Audi" },
TRU: { code: "AU1587", brand: "Audi" },
// Volkswagen
WVW: { code: "VW1587", brand: "Volkswagen" },
WVG: { code: "VW1587", brand: "Volkswagen" },
WV2: { code: "VW1587", brand: "Volkswagen" },
// Renault
VF1: { code: "RENAULT201910", brand: "Renault" },
VF7: { code: "CPSA01", brand: "Peugeot" },
VF3: { code: "CPSA01", brand: "Peugeot" },
ZFA: { code: "CFIAT84", brand: "Fiat" },
// Peugeot
VF3: { code: "PEUGEOT00", brand: "Peugeot" },
// Citroen/Peugeot (VF7 shared — Peugeot more common)
VF7: { code: "PEUGEOT00", brand: "Peugeot" },
// Fiat
ZFA: { code: "FFIAT84", brand: "Fiat" },
// Alfa Romeo
ZAR: { code: "RFIAT84", brand: "Alfa Romeo" },
// Ford
WF0: { code: "FORD202201", brand: "Ford" },
NM0: { code: "FORD202201", brand: "Ford" },
// Toyota
JTD: { code: "TOYOTA00", brand: "Toyota" },
JTE: { code: "TOYOTA00", brand: "Toyota" },
SHH: { code: "HONDA00", brand: "Honda" },
KNM: { code: "HYUNDAI00", brand: "Hyundai" },
KNA: { code: "KIA00", brand: "Kia" },
JTN: { code: "TOYOTA00", brand: "Toyota" },
// Lexus
JTH: { code: "LEXUS00", brand: "Lexus" },
JTJ: { code: "LEXUS00", brand: "Lexus" },
// Honda
SHH: { code: "HONDA2017", brand: "Honda" },
// Hyundai
KMH: { code: "HYUNDAI202404", brand: "Hyundai" },
KNM: { code: "HYUNDAI202404", brand: "Hyundai" },
// Kia
KNA: { code: "KIA202404", brand: "Kia" },
KNE: { code: "KIA202404", brand: "Kia" },
// Porsche
WP0: { code: "PO799", brand: "Porsche" },
WP1: { code: "PO799", brand: "Porsche" },
// Subaru
JF1: { code: "SUBARU201802", brand: "Subaru" },
JF2: { code: "SUBARU201802", brand: "Subaru" },
// Mazda
JMZ: { code: "MAZDA2020", brand: "Mazda" },
JM1: { code: "MAZDA2020", brand: "Mazda" },
JM3: { code: "MAZDA2020", brand: "Mazda" },
// Mitsubishi
JMY: { code: "MMC202501", brand: "Mitsubishi" },
JMB: { code: "MMC202501", brand: "Mitsubishi" },
JA3: { code: "MMC202501", brand: "Mitsubishi" },
JA4: { code: "MMC202501", brand: "Mitsubishi" },
JA7: { code: "MMC202501", brand: "Mitsubishi" },
// Nissan
JN1: { code: "NISSAN201809", brand: "Nissan" },
JN8: { code: "NISSAN201809", brand: "Nissan" },
VSK: { code: "NISSAN201809", brand: "Nissan" },
// Volvo
YV1: { code: "VOLVO201410", brand: "Volvo" },
YV4: { code: "VOLVO201410", brand: "Volvo" },
// MINI
WMW: { code: "MINI202501", brand: "Mini" },
// Jaguar
SAJ: { code: "JAGUAR201701", brand: "Jaguar" },
// Land Rover
SAL: { code: "LRE201412", brand: "Land Rover" },
// Skoda
TMB: { code: "SK1119", brand: "Skoda" },
// SEAT
VSS: { code: "SE1113", brand: "Seat" },
// Dacia
UU1: { code: "DACIA201910", brand: "Dacia" },
// Suzuki
JSA: { code: "SUZUKI201905", brand: "Suzuki" },
TSM: { code: "SUZUKI201905", brand: "Suzuki" },
// Isuzu
JAA: { code: "ISUZU201702", brand: "Isuzu" },
// Opel
W0L: { code: "GM_OP201809", brand: "Opel" },
// Chevrolet
KL1: { code: "GM_C201809", brand: "Chevrolet" },
// SsangYong
KPT: { code: "SY201502", brand: "SsangYong" },
// Chrysler/Jeep/Dodge/RAM
"1C4": { code: "JEEP202402", brand: "Jeep" },
"3C4": { code: "CHRYSLER202402", brand: "Chrysler" },
// Rolls-Royce
SCA: { code: "RR202501", brand: "Rolls-Royce" },
// Smart
WME: { code: "MBS201810", brand: "Smart" },
// Infiniti
JNK: { code: "INFINITI201809", brand: "Infiniti" },
};

View File

@@ -7,6 +7,8 @@ import {
emexPartNumbers,
emexParts,
emexScrapeSessions,
emexVehicleGroupLinks,
emexVehiclePartLinks,
emexVehicleVins,
emexVehicles,
} from "../../database/schema/emex";
@@ -58,7 +60,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) {
@@ -79,20 +80,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

@@ -1,8 +1,8 @@
import { Job } from "bullmq";
import { sql as drizzleSql, inArray } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import OpenAI from "openai";
import Redis from "ioredis";
import OpenAI from "openai";
import { emexCategoryTranslations } from "../../database/schema/core";
// Schema-loose local alias (matches the shape used by worker.ts which builds

View File

@@ -1,5 +1,6 @@
import { Module } from "@nestjs/common";
import { BrandsModule } from "../brands/brands.module";
import { CatalogModule } from "../catalog/catalog.module";
import { CategoriesModule } from "../categories/categories.module";
import { CorgiModule } from "../integrations/corgi/corgi.module";
import { EmexModule } from "../integrations/emex/emex.module";
@@ -19,6 +20,7 @@ import { VehiclesService } from "./vehicles.service";
PartsCatalogsModule,
BrandsModule,
CategoriesModule,
CatalogModule,
JobsModule,
],
controllers: [VehiclesController],

View File

@@ -0,0 +1,822 @@
/**
* Static mapping: EMEX group_id → category hierarchy path.
* Covers all 795 unique group_ids across 53 catalogs.
* Generated from emexdwc.ae QuickGroups + name-based auto-categorization.
*/
export const EMEX_GROUP_HIERARCHY: Record<string, string[]> = {
"10105": [],
"10125": ["Brake System"],
"10126": ["Brake System"],
"10128": ["Brake System"],
"10129": ["Brake System"],
"10130": ["Brake System", "Disc Brake"],
"10131": ["Brake System", "Drum Brake"],
"10132": ["Brake System", "Disc Brake"],
"10133": ["Brake System", "Drum Brake"],
"10134": ["Brake System"],
"10135": ["Brake System"],
"10136": ["Brake System"],
"10137": ["Brake System"],
"10138": ["Brake System"],
"10139": ["Brake System"],
"10140": ["Electrics", "Starter System"],
"10141": ["Electrics"],
"10142": ["Electrics"],
"10147": ["Exhaust System"],
"10148": ["Exhaust System"],
"10151": ["Clutch/ Parts"],
"10152": ["Clutch/ Parts"],
"10153": ["Clutch/ Parts"],
"10154": ["Clutch/ Parts"],
"10155": ["Clutch/ Parts", "Releaser, clutch"],
"10156": ["Clutch/ Parts", "Releaser, clutch"],
"10157": ["Clutch/ Parts"],
"10159": ["Clutch/ Parts"],
"10160": ["Clutch/ Parts", "Clutch Control"],
"10161": ["Clutch/ Parts", "Clutch Control"],
"10162": ["Wheel Drive"],
"10166": ["Wheel Drive"],
"10170": ["Wheel Drive"],
"10171": ["Wheel Drive"],
"10174": ["Wheel Drive"],
"10177": ["Clutch/ Parts", "Clutch Control"],
"10185": ["Belt Drive"],
"10188": ["Cooling System"],
"10189": ["Cooling System", "Water Pump/ Gasket"],
"10191": ["Cooling System", "Water Pump/ Gasket"],
"10194": ["Cooling System"],
"10195": ["Cooling System", "Thermostat/ Gasket"],
"10196": ["Cooling System", "Thermostat/ Gasket"],
"10199": ["Cooling System"],
"10200": ["Cooling System", "Hoses/ Pipes/ Flanges"],
"10203": ["Cooling System", "Radiator/ Oil Cooler"],
"10204": ["Cooling System", "Radiator/ Oil Cooler"],
"10205": ["Cooling System", "Radiator/ Oil Cooler"],
"10208": ["Cooling System", "Radiator/ Oil Cooler"],
"10212": ["Cooling System", "Radiator/ Oil Cooler"],
"10213": ["Axle Mounting/ Steering/ Wheels"],
"10221": ["Axle Mounting/ Steering/ Wheels"],
"10229": ["Axle Mounting/ Steering/ Wheels", "Suspension Parts"],
"10233": ["Windscreen Cleaning System"],
"10234": ["Windscreen Cleaning System"],
"10235": ["Windscreen Cleaning System"],
"10236": ["Windscreen Cleaning System"],
"10237": ["Windscreen Cleaning System"],
"10247": ["Electrics", "Lights"],
"10248": ["Spark/ Glow Ignition"],
"10250": ["Spark/ Glow Ignition"],
"10251": ["Spark/ Glow Ignition"],
"10252": ["Spark/ Glow Ignition"],
"10253": ["Spark/ Glow Ignition"],
"10255": ["Body", "Body Parts/ Wing/ Bumper"],
"10264": ["Body", "Body Parts/ Wing/ Bumper"],
"10265": ["Body", "Body Parts/ Wing/ Bumper"],
"10266": ["Body", "Body Parts/ Wing/ Bumper"],
"10268": ["Body", "Body Parts/ Wing/ Bumper"],
"10269": ["Body", "Body Parts/ Wing/ Bumper"],
"10281": ["Body", "Body Parts/ Wing/ Bumper"],
"10284": ["Body", "Hatches/Hoods/Doors/Sunroof/Folding Roof"],
"10285": ["Body", "Body Parts/ Wing/ Bumper"],
"10287": ["Body", "Body Parts/ Wing/ Bumper"],
"10289": ["Body", "Hatches/Hoods/Doors/Sunroof/Folding Roof"],
"10291": ["Body", "Body Parts/ Wing/ Bumper"],
"10292": ["Body", "Hatches/Hoods/Doors/Sunroof/Folding Roof"],
"10295": ["Exhaust System"],
"10296": ["Exhaust System"],
"10297": ["Axle Mounting/ Steering/ Wheels", "Tie Rod Assembly/ Parts"],
"10298": ["Axle Mounting/ Steering/ Wheels"],
"10299": ["Axle Mounting/ Steering/ Wheels"],
"10300": ["Axle Mounting/ Steering/ Wheels"],
"10301": ["Axle Mounting/ Steering/ Wheels"],
"10302": ["Axle Mounting/ Steering/ Wheels"],
"10303": ["Axle Mounting/ Steering/ Wheels"],
"10307": ["Axle Mounting/ Steering/ Wheels"],
"10308": ["Axle Mounting/ Steering/ Wheels"],
"10309": ["Axle Mounting/ Steering/ Wheels"],
"10310": ["Axle Mounting/ Steering/ Wheels"],
"10311": ["Axle Mounting/ Steering/ Wheels"],
"10312": ["Axle Mounting/ Steering/ Wheels"],
"10313": ["Axle Mounting/ Steering/ Wheels"],
"10315": ["Cooling System", "Hoses/ Pipes/ Flanges"],
"10317": ["Engine"],
"10324": ["Engine", "Gaskets"],
"10325": ["Engine", "Gaskets"],
"10327": ["Engine", "Gaskets"],
"10328": ["Engine", "Gaskets"],
"10329": ["Engine", "Gaskets"],
"10331": ["Engine", "Gaskets"],
"10333": ["Engine", "Gaskets"],
"10334": ["Engine", "Gaskets"],
"10337": ["Clutch/ Parts", "Releaser, clutch"],
"10344": ["Engine", "Gaskets"],
"10346": ["Spark/ Glow Ignition"],
"10347": ["Spark/ Glow Ignition"],
"10349": ["Spark/ Glow Ignition"],
"10350": ["Fuel Supply System"],
"10352": ["Fuel Supply System"],
"10353": ["Fuel Supply System"],
"10358": ["Fuel Supply System"],
"10359": ["Filters"],
"10360": ["Filters"],
"10361": ["Filters"],
"10362": ["Filters"],
"10363": ["Filters"],
"10367": ["Fuel Supply System"],
"10368": ["Fuel Supply System"],
"10369": ["Fuel Supply System"],
"10370": ["Fuel Supply System"],
"10376": ["Electrics", "Auxiliary Lights/ Parts"],
"10377": ["Electrics", "Auxiliary Lights/ Parts"],
"10379": ["Electrics", "Headlight/ Parts"],
"10384": ["Electrics", "Lights"],
"10385": ["Electrics", "Lights"],
"10387": ["Electrics", "Lights"],
"10389": ["Body", "Lighting"],
"10391": ["Electrics", "Lights"],
"10392": ["Electrics", "Lights"],
"10395": ["Electrics"],
"10398": ["Clutch/ Parts", "Clutch Control"],
"10414": ["Exhaust System"],
"10415": ["Exhaust System"],
"10416": ["Exhaust System", "Assembly Parts"],
"10418": ["Exhaust System"],
"10419": ["Exhaust System"],
"10420": ["Electrics"],
"10421": ["Exhaust System", "Assembly Parts", "Individual Assembly Parts"],
"10423": ["Exhaust System", "Assembly Parts", "Individual Assembly Parts"],
"10425": ["Exhaust System", "Assembly Parts", "Individual Assembly Parts"],
"10427": ["Exhaust System", "Assembly Parts", "Individual Assembly Parts"],
"10428": ["Engine"],
"10430": ["Engine"],
"10432": ["Engine"],
"10433": ["Exhaust System", "Assembly Parts", "Individual Assembly Parts"],
"10434": ["Cooling System"],
"10437": ["Cooling System"],
"10438": ["Cooling System"],
"10440": ["Axle Mounting/ Steering/ Wheels"],
"10442": [],
"10444": ["Cooling System"],
"10446": ["Heater"],
"10447": ["Heater"],
"10448": ["Heater"],
"10449": ["Heater"],
"10450": ["Electrics", "Alternator/- Parts"],
"10451": ["Electrics", "Alternator/- Parts"],
"10452": ["Electrics", "Alternator/- Parts"],
"10454": ["Air Conditioning"],
"10455": ["Air Conditioning"],
"10456": ["Air Conditioning"],
"10457": ["Air Conditioning"],
"10458": ["Air Conditioning"],
"10459": ["Electrics", "Starter System"],
"10460": ["Air Conditioning"],
"10462": ["Electrics", "Starter System"],
"10463": ["Air Conditioning"],
"10464": ["Transmission"],
"10465": ["Transmission"],
"10466": ["Air Conditioning"],
"10467": ["Carrier Equipment"],
"10470": ["Axle Mounting/ Steering/ Wheels"],
"10471": ["Axle Mounting/ Steering/ Wheels"],
"10472": ["Axle Mounting/ Steering/ Wheels", "Suspension Parts"],
"10474": ["Engine", "Cylinder Head/ Parts"],
"10475": ["Engine", "Cylinder Head/ Parts"],
"10476": ["Engine", "Cylinder Head/ Parts"],
"10477": ["Engine", "Cylinder Head/ Parts"],
"10478": ["Engine", "Cylinder Head/ Parts"],
"10479": ["Engine", "Cylinder Head/ Parts"],
"10480": ["Engine", "Cylinder Head/ Parts"],
"10481": ["Axle Mounting/ Steering/ Wheels"],
"10482": ["Fuel Mixture Formation"],
"10484": ["Engine", "Engine Air Supply"],
"10485": ["Engine", "Engine Air Supply"],
"10486": ["Engine", "Engine Air Supply"],
"10487": ["Engine", "Engine Air Supply"],
"10488": ["Engine", "Engine Air Supply"],
"10489": ["Exhaust System"],
"10491": ["Engine", "Engine Air Supply", "Charger (Turbo-/ Supercharger)"],
"10493": ["Engine", "Engine Air Supply", "Charger (Turbo-/ Supercharger)"],
"10494": ["Engine", "Engine Air Supply", "Charger (Turbo-/ Supercharger)"],
"10496": ["Engine", "Engine Timing Control"],
"10497": ["Engine", "Engine Timing Control"],
"10498": ["Engine", "Engine Timing Control"],
"10499": ["Engine", "Engine Timing Control"],
"10503": ["Engine", "Engine Timing Control"],
"10504": ["Engine", "Engine Timing Control", "Timing Belt/ Tensioner/ Guide"],
"10505": ["Engine", "Engine Timing Control", "Timing Belt/ Tensioner/ Guide"],
"10506": ["Engine", "Engine Timing Control", "Timing Belt/ Tensioner/ Guide"],
"10507": ["Engine", "Engine Timing Control", "Timing Belt/ Tensioner/ Guide"],
"10510": ["Engine", "Engine Timing Control", "Timing Belt/ Tensioner/ Guide"],
"10511": ["Engine", "Engine Timing Control", "Timing Chain/ Tensioner/ Guide"],
"10513": ["Engine", "Engine Timing Control", "Timing Chain/ Tensioner/ Guide"],
"10514": ["Engine", "Engine Timing Control", "Timing Chain/ Tensioner/ Guide"],
"10515": ["Axle Drive"],
"10516": ["Axle Drive"],
"10519": ["Security Systems"],
"10520": ["Security Systems"],
"10521": ["Security Systems"],
"10523": ["Engine", "Engine Timing Control", "Valve Train"],
"10524": ["Engine", "Engine Timing Control", "Valve Train"],
"10525": ["Engine", "Engine Timing Control"],
"10527": ["Electrics", "Headlight/ Parts"],
"10530": ["Electrics", "Headlight/ Parts"],
"10531": ["Belt Drive", "V-Ribbed Belt / Set"],
"10532": ["Belt Drive", "V-Ribbed Belt / Set"],
"10533": ["Electrics", "Headlight/ Parts"],
"10534": ["Belt Drive", "V-Ribbed Belt / Set"],
"10535": ["Belt Drive", "V-Ribbed Belt / Set"],
"10538": ["Engine"],
"10539": ["Electrics", "Auxiliary Lights/ Parts", "Spotlight/ Parts"],
"10540": ["Electrics", "Auxiliary Lights/ Parts", "Fog Light/ Parts"],
"10541": ["Engine"],
"10542": ["Electrics", "Auxiliary Lights/ Parts", "Spotlight/ Parts"],
"10543": ["Body", "Lighting"],
"10544": ["Electrics", "Auxiliary Lights/ Parts", "Fog Light/ Parts"],
"10547": ["Electrics", "Auxiliary Lights/ Parts", "Fog Light/ Parts"],
"10552": ["Belt Drive", "Timing Belt / Set"],
"10553": ["Belt Drive", "Timing Belt / Set"],
"10554": ["Electrics", "Lights", "Combination Rearlight/-Parts"],
"10556": ["Electrics", "Lights", "Combination Rearlight/-Parts"],
"10557": ["Belt Drive", "Timing Belt / Set"],
"10560": ["Body", "Lighting"],
"10561": ["Belt Drive", "Timing Belt / Set"],
"10563": ["Electrics", "Lights", "Indicator/ Parts"],
"10564": ["Electrics", "Lights", "Licence Plate Light/-Parts"],
"10565": ["Electrics", "Lights", "Rear Fog Light/ Parts"],
"10566": ["Electrics", "Lights", "Reverse Light/ Parts"],
"10567": ["Body", "Lighting"],
"10568": ["Electrics", "Lights", "Side-/Marker Light/-Parts"],
"10569": ["Electrics", "Lights", "Side-/Marker Light/-Parts"],
"10570": ["Engine", "Lubrication"],
"10571": ["Body", "Lighting"],
"10572": ["Engine", "Lubrication"],
"10574": ["Engine", "Lubrication"],
"10575": ["Engine", "Lubrication"],
"10577": ["Engine"],
"10578": ["Engine", "Lubrication"],
"10579": ["Engine", "Lubrication"],
"10580": ["Body", "Lighting"],
"10581": ["Engine", "Lubrication"],
"10582": ["Body"],
"10583": ["Engine", "Lubrication", "Oil Cooler/ Parts"],
"10584": ["Body", "Lighting"],
"10586": ["Body", "Lighting"],
"10587": ["Body", "Lighting"],
"10588": ["Engine", "Lubrication", "Oil Cooler/ Parts"],
"10589": ["Engine", "Lubrication", "Oil Pan/ Parts"],
"10590": ["Engine", "Lubrication", "Oil Pan/ Parts"],
"10591": ["Engine", "Lubrication", "Oil Pan/ Parts"],
"10592": ["Engine", "Lubrication", "Oil Pump/ Parts"],
"10593": ["Engine", "Lubrication", "Oil Pump/ Parts"],
"10594": ["Engine", "Lubrication", "Oil Pump/ Parts"],
"10595": ["Engine"],
"10596": ["Electrics", "Lights", "Stop Light/ Parts"],
"10598": ["Electrics", "Lights", "Indicator/ Parts"],
"10599": ["Electrics", "Lights", "Licence Plate Light/-Parts"],
"10600": ["Electrics", "Lights", "Rear Fog Light/ Parts"],
"10601": ["Electrics", "Lights", "Reverse Light/ Parts"],
"10603": ["Electrics", "Lights", "Side-/Marker Light/-Parts"],
"10605": ["Electrics", "Lights", "Interior Lights"],
"10606": ["Engine"],
"10607": ["Electrics", "Lights", "Interior Lights"],
"10608": ["Electrics", "Lights", "Interior Lights"],
"10609": ["Electrics", "Light Switches/ Relays/ Controls"],
"10610": ["Electrics"],
"10611": ["Electrics"],
"10612": ["Engine"],
"10613": ["Engine"],
"10616": ["Body"],
"10617": ["Engine", "Crankshaft Drive"],
"10618": ["Engine", "Crankshaft Drive"],
"10619": ["Engine", "Crankshaft Drive"],
"10620": ["Engine", "Crankshaft Drive"],
"10621": ["Engine", "Crankshaft Drive"],
"10622": ["Engine", "Crankshaft Drive", "Crankshaft"],
"10623": ["Engine", "Crankshaft Drive", "Crankshaft"],
"10624": ["Engine", "Crankshaft Drive", "Connecting Rod Assembly"],
"10625": ["Engine", "Crankshaft Drive", "Connecting Rod Assembly"],
"10627": ["Engine", "Crankshaft Drive", "Connecting Rod Assembly"],
"10628": ["Engine", "Crankshaft Drive", "Connecting Rod Assembly"],
"10629": ["Engine", "Crankshaft Drive", "Piston Assembly"],
"10630": ["Engine", "Crankshaft Drive", "Piston Assembly"],
"10631": ["Engine", "Crankshaft Drive", "Piston Assembly"],
"10632": ["Engine", "Crankshaft Drive", "Piston Assembly"],
"10633": ["Engine", "Crankcase"],
"10634": ["Engine", "Crankcase"],
"10635": ["Engine"],
"10636": ["Engine", "Engine Mountings"],
"10637": ["Body", "Body Parts/ Wing/ Bumper"],
"10638": ["Engine", "Engine Mountings"],
"10639": ["Engine"],
"10642": ["Engine", "Exhaust Emission Control"],
"10643": ["Fuel Mixture Formation"],
"10651": ["Engine", "Exhaust Emission Control", "Exhaust Gas Recirculation (EGR)"],
"10652": ["Engine", "Exhaust Emission Control"],
"10653": ["Electrics", "Instruments"],
"10655": ["Electrics", "Instruments"],
"10656": ["Engine", "Exhaust Emission Control", "Secondary Air Injection"],
"10659": ["Engine", "Exhaust Emission Control", "Secondary Air Injection"],
"10660": ["Engine", "Exhaust Emission Control", "Secondary Air Injection"],
"10661": ["Electrics", "Instruments"],
"10665": ["Axle Mounting/ Steering/ Wheels"],
"10666": ["Body", "Windows/ Mirrors"],
"10667": ["Body", "Windows/ Mirrors", "Windows"],
"10668": ["Body", "Windows/ Mirrors", "Windows"],
"10669": ["Body", "Windows/ Mirrors", "Windows"],
"10671": ["Axle Mounting/ Steering/ Wheels", "Control Arm/Swing Arm Joint"],
"10672": ["Axle Mounting/ Steering/ Wheels", "Control Arm/Swing Arm Joint"],
"10673": ["Axle Mounting/ Steering/ Wheels", "Stabilizer/ Fasteners"],
"10674": ["Axle Mounting/ Steering/ Wheels"],
"10677": ["Axle Mounting/ Steering/ Wheels", "Stabilizer/ Fasteners"],
"10678": ["Axle Mounting/ Steering/ Wheels", "Wheel Hub / Mounting"],
"10679": ["Axle Mounting/ Steering/ Wheels", "Wheel Hub / Mounting"],
"10680": ["Axle Mounting/ Steering/ Wheels", "Wheel Hub / Mounting"],
"10681": ["Axle Mounting/ Steering/ Wheels", "Joints"],
"10685": ["Axle Mounting/ Steering/ Wheels", "Axle Support / Axle Body / Axle Mounting"],
"10686": ["Axle Mounting/ Steering/ Wheels", "Axle Support / Axle Body / Axle Mounting"],
"10687": ["Axle Mounting/ Steering/ Wheels", "Stub Axle Repair Kit"],
"10688": ["Axle Mounting/ Steering/ Wheels"],
"10689": ["Axle Mounting/ Steering/ Wheels", "Stub Axle Repair Kit"],
"10690": ["Axle Mounting/ Steering/ Wheels", "Stabilizer/ Fasteners"],
"10691": ["Axle Mounting/ Steering/ Wheels"],
"10692": ["Axle Mounting/ Steering/ Wheels"],
"10693": ["Axle Mounting/ Steering/ Wheels"],
"10694": ["Axle Mounting/ Steering/ Wheels"],
"10696": ["Axle Mounting/ Steering/ Wheels", "Stabilizer/ Fasteners"],
"10697": ["Maintenance Service Parts"],
"10698": ["Maintenance Service Parts"],
"10701": ["Axle Mounting/ Steering/ Wheels"],
"10702": ["Axle Mounting/ Steering/ Wheels", "Tie Rod Assembly/ Parts"],
"10703": ["Axle Mounting/ Steering/ Wheels", "Tie Rod Assembly/ Parts"],
"10706": ["Body", "Body Parts/ Wing/ Bumper"],
"10707": ["Towbar/ Parts"],
"10708": ["Towbar/ Parts"],
"10709": ["Towbar/ Parts"],
"10710": ["Windscreen Cleaning System"],
"10711": ["Windscreen Cleaning System"],
"10712": ["Body"],
"10713": ["Windscreen Cleaning System"],
"10714": ["Comfort Systems"],
"10715": ["Comfort Systems"],
"10716": ["Electrics"],
"10717": ["Comfort Systems"],
"10718": ["Comfort Systems"],
"10719": ["Comfort Systems"],
"10721": ["Comfort Systems", "Motor/ Relay/ Switch"],
"10722": ["Comfort Systems", "Motor/ Relay/ Switch"],
"10723": ["Comfort Systems", "Motor/ Relay/ Switch"],
"10724": ["Body"],
"10725": ["Comfort Systems"],
"10726": ["Brake System"],
"10730": ["Brake System", "Disc Brake"],
"10731": ["Brake System"],
"10734": ["Brake System", "Drum Brake"],
"10735": ["Brake System"],
"10773": ["Interior Equipment"],
"10780": ["Security Systems"],
"10782": ["Engine", "Gaskets"],
"10783": ["Engine", "Gaskets"],
"10784": ["Engine", "Gaskets"],
"10786": ["Locking System"],
"10787": ["Locking System"],
"10788": ["Locking System"],
"10789": ["Locking System"],
"10790": ["Locking System"],
"10791": ["Locking System"],
"10793": ["Information/ Communication Systems"],
"10794": ["Information/ Communication Systems"],
"10795": ["Information/ Communication Systems"],
"10796": ["Information/ Communication Systems"],
"10797": ["Information/ Communication Systems"],
"10799": ["Body", "Body Parts/ Wing/ Bumper"],
"10800": ["Engine"],
"10801": ["Engine", "Complete Engine/ Sub-Assembly"],
"10802": ["Engine", "Complete Engine/ Sub-Assembly"],
"10803": ["Axle Drive"],
"10804": ["Engine", "Engine Air Supply"],
"10806": ["Engine", "Engine Air Supply", "Throttle/ Sensor"],
"10807": ["Axle Drive", "Propshaft"],
"10808": ["Electrics"],
"10812": ["Clutch/ Parts", "Clutch Control"],
"10813": ["Engine"],
"10814": ["Exhaust System"],
"10815": ["Exhaust System"],
"10817": ["Fuel Supply System", "Fuel Pump / Parts"],
"10818": ["Fuel Supply System", "Fuel Pump / Parts"],
"10819": ["Engine"],
"10823": ["Electrics", "Headlight/ Parts"],
"10824": ["Electrics", "Light Switches/ Relays/ Controls"],
"10825": ["Heater"],
"10826": ["Heater"],
"10827": ["Electrics"],
"10828": ["Electrics"],
"10829": ["Engine", "Exhaust Emission Control"],
"10830": ["Interior Equipment"],
"10832": ["Air Conditioning"],
"10833": [],
"10835": ["Axle Mounting/ Steering/ Wheels"],
"10837": ["Body", "Body Parts/ Wing/ Bumper"],
"10839": ["Body", "Body Parts/ Wing/ Bumper"],
"10840": ["Body", "Trim/ Protection/ Decorative Strips/ Emblems"],
"10841": ["Body", "Trim/ Protection/ Decorative Strips/ Emblems"],
"10845": ["Body", "Windows/ Mirrors"],
"10851": ["Comfort Systems"],
"10852": ["Axle Mounting/ Steering/ Wheels"],
"10853": ["Electrics", "Lights", "Side-/Marker Light/-Parts"],
"10854": ["Engine"],
"10857": ["Axle Mounting/ Steering/ Wheels"],
"10858": ["Axle Mounting/ Steering/ Wheels"],
"10859": ["Engine", "Crankshaft Drive", "Crankshaft"],
"10860": ["Engine", "Crankcase"],
"10861": ["Axle Mounting/ Steering/ Wheels", "Axle Support / Axle Body / Axle Mounting"],
"10862": ["Security Systems"],
"10866": ["Locking System"],
"10867": ["Engine", "Crankcase"],
"10868": ["Axle Drive"],
"10869": ["Transmission", "Manual Transmission"],
"10870": ["Transmission", "Automatic Transmission"],
"10872": ["Transmission", "Manual Transmission"],
"10873": ["Transmission", "Automatic Transmission"],
"10874": ["Transmission", "Manual Transmission"],
"10875": ["Transmission", "Automatic Transmission"],
"10876": ["Transmission", "Automatic Transmission"],
"10877": ["Heater"],
"10878": ["Body", "Hatches/Hoods/Doors/Sunroof/Folding Roof"],
"10879": ["Air Conditioning"],
"10880": ["Clutch/ Parts"],
"10885": ["Electrics"],
"10886": ["Engine"],
"10888": ["Engine"],
"10889": ["Engine", "Gaskets"],
"10891": ["Fuel Mixture Formation", "Exhaust Emission Control"],
"10892": ["Fuel Mixture Formation", "Exhaust Emission Control"],
"10893": ["Fuel Mixture Formation", "Exhaust Emission Control"],
"10894": ["Air Conditioning"],
"10903": ["Axle Mounting/ Steering/ Wheels", "Wheel Hub / Mounting"],
"10905": ["Axle Drive", "Propshaft"],
"10906": ["Brake System", "Brake Calipers"],
"10907": ["Brake System", "Brake Calipers"],
"10908": ["Fuel Mixture Formation"],
"10909": ["Axle Mounting/ Steering/ Wheels"],
"10910": ["Axle Mounting/ Steering/ Wheels"],
"10911": [
"Fuel Mixture Formation",
"Exhaust Emission Control",
"Exhaust Gas Recirculation (EGR)",
],
"10912": ["Engine"],
"10918": ["Engine", "Engine Air Supply"],
"10919": ["Locking System"],
"10921": ["Fuel Mixture Formation", "Exhaust Emission Control", "Secondary Air Intake"],
"10922": ["Fuel Mixture Formation", "Exhaust Emission Control", "Secondary Air Intake"],
"10925": ["Fuel Mixture Formation", "Exhaust Emission Control", "Secondary Air Intake"],
"10926": ["Body"],
"10927": ["Spark/ Glow Ignition"],
"10931": ["Clutch/ Parts"],
"10932": ["Clutch/ Parts"],
"10934": ["Axle Mounting/ Steering/ Wheels"],
"10936": ["Comfort Systems"],
"10938": ["Engine"],
"10939": ["Axle Drive", "Propshaft"],
"10940": ["Cooling System", "Hoses/ Pipes/ Flanges"],
"10948": ["Electrics"],
"10950": ["Comfort Systems"],
"10952": ["Engine"],
"10954": ["Accessories"],
"10957": ["Accessories"],
"10958": ["Accessories"],
"10960": ["Accessories"],
"10962": ["Accessories"],
"10963": ["Comfort Systems"],
"10965": ["Air Conditioning"],
"10967": ["Electrics", "Lights", "Stop Light/ Parts"],
"10969": ["Electrics", "Auxiliary Lights/ Parts"],
"10970": ["Electrics", "Lights"],
"10972": ["Exhaust System"],
"10974": ["Engine"],
"10976": ["Engine"],
"10978": ["Electrics"],
"10979": ["Electrics"],
"10980": ["Comfort Systems", "Motor/ Relay/ Switch"],
"10981": ["Comfort Systems", "Motor/ Relay/ Switch"],
"10982": ["Comfort Systems", "Motor/ Relay/ Switch"],
"10983": ["Electrics"],
"10984": ["Body", "Lighting"],
"10985": ["Engine", "Lubrication", "Oil Pump/ Parts"],
"10986": ["Engine", "Crankshaft Drive", "Crankshaft"],
"11441": ["Body", "Auxiliary Lights/ Parts"],
"11442": ["Body", "Auxiliary Lights/ Parts", "Fog Light/ Parts"],
"11443": ["Body", "Auxiliary Lights/ Parts", "Fog Light/ Parts"],
"11444": ["Body", "Auxiliary Lights/ Parts", "Fog Light/ Parts"],
"11445": ["Body", "Auxiliary Lights/ Parts"],
"11446": ["Body", "Auxiliary Lights/ Parts", "Spotlight/ Parts"],
"11447": ["Body", "Auxiliary Lights/ Parts", "Spotlight/ Parts"],
"11448": ["Body", "Lighting"],
"11508": ["Body", "Lights"],
"11509": ["Body", "Lights", "Combination Rearlight/-Parts"],
"11510": ["Body", "Lights", "Combination Rearlight/-Parts"],
"11511": ["Body", "Lights"],
"11512": ["Body", "Lights", "Taillight/ Parts"],
"11513": ["Body", "Lighting"],
"11514": ["Body", "Lights", "Taillight/ Parts"],
"11515": ["Body", "Lights"],
"11516": ["Body", "Lights", "Stop Light/ Parts"],
"11517": ["Body", "Lighting"],
"11518": ["Body", "Lighting"],
"11519": ["Body", "Lights", "Stop Light/ Parts"],
"11521": ["Body", "Lights", "Indicator/ Parts"],
"11522": ["Body", "Lighting"],
"11523": ["Body", "Lights", "Indicator/ Parts"],
"11524": ["Body", "Lights"],
"11525": ["Body", "Lights", "Licence Plate Light/-Parts"],
"11526": ["Body"],
"11527": ["Body", "Lights", "Licence Plate Light/-Parts"],
"11528": ["Body", "Lights"],
"11529": ["Body", "Lights", "Rear Fog Light/ Parts"],
"11530": ["Body", "Lighting"],
"11531": ["Body", "Lights", "Rear Fog Light/ Parts"],
"11532": ["Body", "Lights"],
"11533": ["Body", "Lights", "Reverse Light/ Parts"],
"11534": ["Body", "Lighting"],
"11535": ["Body", "Lights", "Reverse Light/ Parts"],
"11542": ["Body", "Lights", "Side-/Marker Light/-Parts"],
"11543": ["Body", "Lights", "Side-/Marker Light/-Parts"],
"11544": ["Body", "Lights", "Side-/Marker Light/-Parts"],
"11545": ["Body", "Lights", "Side-/Marker Light/-Parts"],
"11546": ["Engine"],
"11552": ["Body", "Lights"],
"11554": ["Body"],
"11566": ["Body", "Headlight/ Parts"],
"11567": ["Body", "Headlight/ Parts"],
"11568": ["Body", "Headlight/ Parts"],
"11569": ["Body"],
"11582": ["Belt Drive"],
"11585": ["Engine", "Engine Timing Control", "Timing Chain/ Tensioner/ Guide"],
"11595": ["Electrics", "Lights", "Combination Rearlight/-Parts"],
"11604": ["Body", "Body Parts/ Wing/ Bumper"],
"11749": ["Body", "Vehicle Front"],
"11750": ["Body", "Vehicle Front"],
"11752": ["Body", "Vehicle Front"],
"11754": ["Body", "Vehicle Front"],
"11755": ["Body", "Vehicle Front"],
"11757": ["Body", "Vehicle Front", "Headlight/ Parts"],
"11758": ["Body", "Vehicle Front", "Headlight/ Parts"],
"11759": ["Body", "Vehicle Front", "Headlight/ Parts"],
"11760": ["Body"],
"11761": ["Body", "Vehicle Front"],
"11762": ["Body", "Vehicle Front", "Fog Light/ Parts"],
"11763": ["Body", "Vehicle Front", "Fog Light/ Parts"],
"11764": ["Body", "Vehicle Front", "Fog Light/ Parts"],
"11765": ["Body", "Vehicle Front"],
"11766": ["Body", "Vehicle Front", "Spotlight/ Parts"],
"11767": ["Body", "Vehicle Front", "Spotlight/ Parts"],
"11768": ["Body", "Lighting"],
"11770": ["Body", "Vehicle Front", "Indicator/ Parts"],
"11771": ["Body", "Lighting"],
"11775": ["Body", "Vehicle Front", "Parts"],
"11779": ["Body", "Vehicle Front", "Parts"],
"11791": ["Body", "Passenger Cabin"],
"11792": ["Body", "Passenger Cabin"],
"11793": ["Body", "Passenger Cabin"],
"11794": ["Body", "Passenger Cabin"],
"11795": ["Body", "Passenger Cabin"],
"11796": ["Body", "Passenger Cabin"],
"11797": ["Body", "Passenger Cabin"],
"11798": ["Body", "Passenger Cabin"],
"11799": ["Body", "Passenger Cabin"],
"11802": ["Body", "Passenger Cabin", "Parts"],
"11803": ["Body", "Passenger Cabin", "Parts"],
"11806": ["Body", "Vehicle Rear"],
"11807": ["Body"],
"11808": ["Body", "Vehicle Rear"],
"11810": ["Body", "Vehicle Rear"],
"11815": ["Body", "Vehicle Rear"],
"11816": ["Body", "Vehicle Rear", "Combination Rearlight/-Parts"],
"11817": ["Body", "Vehicle Rear", "Combination Rearlight/-Parts"],
"11818": ["Body", "Vehicle Rear"],
"11819": ["Body", "Vehicle Rear", "Taillight/ Parts"],
"11820": ["Body", "Lighting"],
"11821": ["Body", "Vehicle Rear", "Taillight/ Parts"],
"11822": ["Body", "Vehicle Rear"],
"11823": ["Body", "Vehicle Rear", "Stop Light/ Parts"],
"11824": ["Body", "Lighting"],
"11825": ["Body", "Lighting"],
"11826": ["Body", "Vehicle Rear", "Stop Light/ Parts"],
"11828": ["Body", "Vehicle Rear", "Indicator/ Parts"],
"11829": ["Body", "Lighting"],
"11831": ["Body", "Vehicle Rear"],
"11832": ["Body", "Vehicle Rear", "Licence Plate Light/-Parts"],
"11833": ["Body"],
"11834": ["Body", "Vehicle Rear", "Licence Plate Light/-Parts"],
"11835": ["Body", "Vehicle Rear"],
"11836": ["Body", "Vehicle Rear", "Rear Fog Light/ Parts"],
"11837": ["Body", "Lighting"],
"11838": ["Body", "Vehicle Rear", "Rear Fog Light/ Parts"],
"11839": ["Body", "Vehicle Rear"],
"11840": ["Body", "Vehicle Rear", "Reverse Light/ Parts"],
"11841": ["Body", "Lighting"],
"11842": ["Body", "Vehicle Rear", "Reverse Light/ Parts"],
"11850": ["Body", "Vehicle Rear", "Parts"],
"11851": ["Body", "Vehicle Rear", "Parts"],
"11852": ["Body", "Vehicle Rear", "Parts"],
"11863": ["Body", "Vehicle Rear"],
"11865": ["Body"],
"11867": ["Body", "Vehicle Front", "Side-/Marker Light/-Parts"],
"11868": ["Body", "Vehicle Front", "Side-/Marker Light/-Parts"],
"11869": ["Body", "Vehicle Front", "Side-/Marker Light/-Parts"],
"11870": ["Body", "Vehicle Front", "Side-/Marker Light/-Parts"],
"11871": ["Engine"],
"11873": ["Body", "Vehicle Front", "Parts"],
"11874": ["Body", "Vehicle Front", "Parts"],
"11875": ["Engine"],
"11877": ["Body", "Vehicle Front", "Parts"],
"11879": ["Body", "Vehicle Rear"],
"11880": ["Body", "Vehicle Rear"],
"11881": ["Body", "Vehicle Rear"],
"11882": ["Body", "Vehicle Rear"],
"11884": ["Body", "Vehicle Rear"],
"11886": ["Body", "Vehicle Rear", "Side-/Marker Light/-Parts"],
"11887": ["Body", "Vehicle Rear", "Side-/Marker Light/-Parts"],
"11888": ["Body", "Vehicle Rear", "Side-/Marker Light/-Parts"],
"11889": ["Body", "Vehicle Rear", "Side-/Marker Light/-Parts"],
"11890": ["Engine"],
"11894": ["Electrics", "Lights", "Interior Lights"],
"11903": ["Electrics", "Lights", "Interior Lights"],
"11908": ["Transmission", "Manual Transmission"],
"11909": ["Transmission", "Manual Transmission"],
"11910": ["Transmission", "Automatic Transmission"],
"11912": ["Transmission", "Automatic Transmission"],
"11914": ["Transmission"],
"11915": ["Transmission", "Automatic Transmission"],
"11917": ["Transmission", "Automatic Transmission"],
"11926": ["Engine"],
"11933": ["Engine", "Engine Timing Control"],
"11953": ["Transmission", "Automatic Transmission"],
"11954": ["Transmission", "Automatic Transmission", "Oil Pan/ Parts"],
"11955": ["Transmission", "Automatic Transmission", "Oil Pan/ Parts"],
"11957": ["Axle Mounting/ Steering/ Wheels"],
"11984": ["Axle Mounting/ Steering/ Wheels"],
"12071": ["Belt Drive"],
"12093": ["Transmission", "Manual Transmission"],
"12094": ["Engine", "Lubrication"],
"12096": ["Axle Drive"],
"12115": ["Engine", "Engine Timing Control", "Timing Belt/ Tensioner/ Guide"],
"12116": ["Belt Drive", "Timing Belt / Set"],
"12173": ["Axle Mounting/ Steering/ Wheels"],
"12176": ["Engine", "Engine Air Supply", "Charger (Turbo-/ Supercharger)"],
"12180": ["Clutch/ Parts", "Releaser, clutch"],
"12182": ["Axle Mounting/ Steering/ Wheels"],
"12301": ["Engine"],
"12303": ["Axle Mounting/ Steering/ Wheels"],
"12305": ["Cooling System"],
"12308": ["Brake System"],
"12311": ["Cooling System", "Water Pump/ Gasket"],
"12315": ["Brake System"],
"12319": ["Heater"],
"12335": ["Engine", "Engine Air Supply"],
"12339": ["Belt Drive"],
"12344": ["Brake System", "Disc Brake"],
"12348": ["Brake System"],
"12350": ["Axle Mounting/ Steering/ Wheels", "Wheel Hub / Mounting"],
"12436": ["Electrics"],
"12439": ["Engine", "Engine Air Supply", "Throttle/ Sensor"],
"12506": ["Body", "Lights", "Indicator/ Parts"],
"12514": ["Body"],
"12547": ["Cooling System"],
"12550": ["Engine", "Cylinder Head/ Parts"],
"12551": ["Exhaust System"],
"12703": ["Fuel Supply System"],
"12761": ["Electrics"],
"12771": ["Electrics", "Auxiliary Lights/ Parts"],
"12783": ["Engine", "Crankshaft Drive", "Crankshaft"],
"12785": ["Transmission", "Manual Transmission"],
"12789": ["Interior Equipment"],
"12790": ["Interior Equipment"],
"12791": ["Interior Equipment"],
"12792": ["Interior Equipment"],
"12793": ["Interior Equipment"],
"12794": ["Interior Equipment"],
"12795": ["Interior Equipment"],
"12796": ["Interior Equipment"],
"12797": ["Interior Equipment"],
"12798": ["Interior Equipment"],
"12806": ["Interior Equipment"],
"12836": ["Comfort Systems"],
"12847": ["Electrics"],
"12854": ["Clutch/ Parts"],
"12858": ["Body", "Passenger Cabin"],
"12860": ["Body", "Hatches/Hoods/Doors/Sunroof/Folding Roof"],
"12863": ["Brake System"],
"12866": ["Axle Mounting/ Steering/ Wheels"],
"12878": ["Engine", "Cylinder Head/ Parts"],
"12881": ["Transmission", "Manual Transmission"],
"12883": ["Transmission", "Automatic Transmission"],
"12894": ["Fuel Mixture Formation", "Mixture Formation"],
"12895": ["Fuel Mixture Formation", "Mixture Formation"],
"12896": ["Fuel Mixture Formation", "Mixture Formation"],
"12898": ["Fuel Mixture Formation"],
"12899": ["Fuel Mixture Formation", "Mixture Formation"],
"12900": ["Fuel Mixture Formation", "Mixture Formation"],
"12901": ["Fuel Mixture Formation", "Mixture Formation"],
"12902": ["Fuel Mixture Formation", "Mixture Formation"],
"12903": ["Fuel Mixture Formation", "Mixture Formation"],
"12904": ["Fuel Mixture Formation", "Mixture Formation"],
"12905": ["Fuel Mixture Formation", "Mixture Formation"],
"12906": ["Fuel Mixture Formation", "Mixture Formation"],
"12907": ["Engine"],
"12908": ["Electrics"],
"12909": ["Fuel Mixture Formation", "Mixture Formation"],
"12910": ["Fuel Mixture Formation", "Mixture Formation"],
"12911": ["Engine"],
"12975": ["Exhaust System"],
"12977": ["Clutch/ Parts"],
"12998": ["Exhaust System"],
"13001": ["Fuel Mixture Formation"],
"13004": ["Fuel Mixture Formation", "Carburettor System"],
"13005": ["Fuel Mixture Formation"],
"13006": ["Fuel Mixture Formation", "Carburettor System"],
"13007": ["Fuel Mixture Formation"],
"13008": ["Fuel Mixture Formation", "Carburettor System"],
"13009": ["Fuel Mixture Formation", "Carburettor System"],
"13010": ["Fuel Mixture Formation", "Carburettor System"],
"13011": ["Fuel Mixture Formation", "Carburettor System"],
"13022": ["Air Conditioning"],
"13028": ["Engine"],
"13030": ["Electrics"],
"13031": ["Transmission", "Automatic Transmission"],
"13064": ["Brake System", "Drum Brake"],
"13065": ["Brake System", "Drum Brake"],
"13092": [
"Fuel Mixture Formation",
"Exhaust Emission Control",
"Exhaust Gas Recirculation (EGR)",
],
"13093": ["Engine", "Exhaust Emission Control", "Exhaust Gas Recirculation (EGR)"],
"13098": ["Interior Equipment"],
"13104": ["Interior Equipment"],
"13119": ["Exhaust System"],
"13120": ["Fuel Mixture Formation"],
"13121": ["Fuel Mixture Formation"],
"13122": ["Exhaust System", "Urea Injection (AdBlue)"],
"13123": ["Fuel Mixture Formation"],
"13135": ["Body", "Vehicle Front"],
"13137": ["Engine"],
"13146": ["Comfort Systems", "Motor/ Relay/ Switch"],
"13147": ["Fuel Mixture Formation", "Exhaust Emission Control"],
"13162": ["Exhaust System", "Urea Injection (AdBlue)"],
"13164": ["Body", "Vehicle Front", "Parts"],
"13165": ["Transmission", "Manual Transmission"],
"13166": ["Electrics"],
"13177": ["Fuel Supply System"],
"13186": ["Fuel Mixture Formation"],
"13189": ["Exhaust System", "Urea Injection (AdBlue)"],
"13191": ["Electrics"],
"13198": ["Body"],
"13199": [],
"13200": ["Wheels/Tyres"],
"13201": ["Wheels/Tyres"],
"13202": ["Wheels/Tyres"],
"13203": ["Transmission"],
"13209": ["Transmission", "Automatic Transmission"],
"13210": ["Cooling System"],
"13211": ["Exhaust System", "Assembly Parts", "Individual Assembly Parts"],
"13218": ["Body", "Lighting"],
"13220": ["Engine", "Crankshaft Drive"],
"13233": ["Axle Mounting/ Steering/ Wheels"],
"13236": ["Accessories"],
"13239": ["Accessories"],
"13256": ["Clutch/ Parts"],
"13271": ["Compressed Air System"],
"13293": ["Brake System"],
"13295": ["Brake System"],
"13297": ["Engine"],
"13298": ["Compressed Air System"],
"13300": ["Compressed Air System", "Valves/Compressed-air System"],
"13302": ["Transmission"],
"13303": ["Engine"],
"13309": ["Transmission"],
"13310": ["Engine"],
"13317": ["Engine"],
"13335": ["Engine", "Exhaust Emission Control", "Exhaust Gas Recirculation (EGR)"],
"13336": ["Engine", "Exhaust Emission Control", "Exhaust Gas Recirculation (EGR)"],
"13337": ["Engine", "Exhaust Emission Control", "Exhaust Gas Recirculation (EGR)"],
"13338": ["Engine", "Exhaust Emission Control", "Exhaust Gas Recirculation (EGR)"],
"13339": [
"Fuel Mixture Formation",
"Exhaust Emission Control",
"Exhaust Gas Recirculation (EGR)",
],
"13340": [
"Fuel Mixture Formation",
"Exhaust Emission Control",
"Exhaust Gas Recirculation (EGR)",
],
"13341": [
"Fuel Mixture Formation",
"Exhaust Emission Control",
"Exhaust Gas Recirculation (EGR)",
],
"13370": ["Engine", "Crankshaft Drive", "Piston Assembly"],
"13372": ["Interior Equipment"],
"13374": ["Accessories"],
};

View File

@@ -48,12 +48,25 @@
"locked": "This brand is not in your plan",
"upgradeCta": "Upgrade Plan",
"loadingModels": "Loading models...",
"tabSasetr": "Sase.Tr",
"tabPl24": "Pl24",
"tabPcat": "Pcat",
"tabEmex": "Emex",
"tabTecdoc": "Tecdoc",
"comingSoon": "Coming Soon",
"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",
"selectCatalog": "Select a catalog",
"selectModel": "Select model",
"resetSelection": "Reset",
"psaVariant": {
"title": "Select Vehicle Variant",
"subtitle": "Optional — use Show All to browse all variants",

View File

@@ -48,12 +48,25 @@
"locked": "Bu marka planınızda yok",
"upgradeCta": "Planını Yükselt",
"loadingModels": "Modeller yükleniyor...",
"tabSasetr": "Sase.Tr",
"tabPl24": "Pl24",
"tabPcat": "Pcat",
"tabEmex": "Emex",
"tabTecdoc": "Tecdoc",
"comingSoon": "Yakında",
"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",
"selectCatalog": "Bir katalog seçin",
"selectModel": "Model seçin",
"resetSelection": "Sıfırla",
"psaVariant": {
"title": "Araç Varyantını Seçin",
"subtitle": "İsteğe bağlı — tüm varyantlar için Tümü seçeneğini kullanın",

View File

@@ -22,6 +22,7 @@ import { Route as AuthRouteImport } from "./routes/_auth"
import { Route as IndexRouteImport } from "./routes/index"
import { Route as DashboardIndexRouteImport } from "./routes/dashboard/index"
import { Route as DashboardSettingsRouteImport } from "./routes/dashboard/settings"
import { Route as DashboardServiceTestRouteImport } from "./routes/dashboard/service-test"
import { Route as DashboardSearchRouteImport } from "./routes/dashboard/search"
import { Route as DashboardHistoryRouteImport } from "./routes/dashboard/history"
import { Route as DashboardBillingRouteImport } from "./routes/dashboard/billing"
@@ -41,9 +42,16 @@ 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 DashboardCatalogPcatCatalogIdIndexRouteImport } from "./routes/dashboard/catalog_/pcat/$catalogId/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 DashboardCatalogPcatCatalogIdModelIdIndexRouteImport } from "./routes/dashboard/catalog_/pcat/$catalogId_/$modelId/index"
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 DashboardCatalogPcatCatalogIdModelIdCarIdIndexRouteImport } from "./routes/dashboard/catalog_/pcat/$catalogId_/$modelId_/$carId/index"
import { Route as DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRouteImport } from "./routes/dashboard/catalog_/emex/$catalogCode_/$vehicleId_/groups/$groupId"
import { Route as DashboardCatalogPcatCatalogIdModelIdCarIdGroupsGroupIdRouteImport } from "./routes/dashboard/catalog_/pcat/$catalogId_/$modelId_/$carId_/groups/$groupId"
const TermsRoute = TermsRouteImport.update({
id: "/terms",
@@ -109,6 +117,11 @@ const DashboardSettingsRoute = DashboardSettingsRouteImport.update({
path: "/settings",
getParentRoute: () => DashboardRoute,
} as any)
const DashboardServiceTestRoute = DashboardServiceTestRouteImport.update({
id: "/service-test",
path: "/service-test",
getParentRoute: () => DashboardRoute,
} as any)
const DashboardSearchRoute = DashboardSearchRouteImport.update({
id: "/search",
path: "/search",
@@ -208,6 +221,18 @@ const DashboardCatalogBrandNameIndexRoute =
path: "/catalog/$brandName/",
getParentRoute: () => DashboardRoute,
} as any)
const DashboardCatalogPcatCatalogIdIndexRoute =
DashboardCatalogPcatCatalogIdIndexRouteImport.update({
id: "/catalog_/pcat/$catalogId/",
path: "/catalog/pcat/$catalogId/",
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 +245,42 @@ const DashboardVehiclesIdCategoriesCategoryIdRoute =
path: "/vehicles/$id/categories/$categoryId",
getParentRoute: () => DashboardRoute,
} as any)
const DashboardCatalogPcatCatalogIdModelIdIndexRoute =
DashboardCatalogPcatCatalogIdModelIdIndexRouteImport.update({
id: "/catalog_/pcat/$catalogId_/$modelId/",
path: "/catalog/pcat/$catalogId/$modelId/",
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 DashboardCatalogPcatCatalogIdModelIdCarIdIndexRoute =
DashboardCatalogPcatCatalogIdModelIdCarIdIndexRouteImport.update({
id: "/catalog_/pcat/$catalogId_/$modelId_/$carId/",
path: "/catalog/pcat/$catalogId/$modelId/$carId/",
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)
const DashboardCatalogPcatCatalogIdModelIdCarIdGroupsGroupIdRoute =
DashboardCatalogPcatCatalogIdModelIdCarIdGroupsGroupIdRouteImport.update({
id: "/catalog_/pcat/$catalogId_/$modelId_/$carId_/groups/$groupId",
path: "/catalog/pcat/$catalogId/$modelId/$carId/groups/$groupId",
getParentRoute: () => DashboardRoute,
} as any)
export interface FileRoutesByFullPath {
"/": typeof IndexRoute
@@ -246,6 +301,7 @@ export interface FileRoutesByFullPath {
"/dashboard/billing": typeof DashboardBillingRoute
"/dashboard/history": typeof DashboardHistoryRoute
"/dashboard/search": typeof DashboardSearchRoute
"/dashboard/service-test": typeof DashboardServiceTestRoute
"/dashboard/settings": typeof DashboardSettingsRoute
"/dashboard/": typeof DashboardIndexRoute
"/dashboard/admin/analytics": typeof DashboardAdminAnalyticsRoute
@@ -261,7 +317,14 @@ 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/pcat/$catalogId/": typeof DashboardCatalogPcatCatalogIdIndexRoute
"/dashboard/catalog/$brandName/$modelId/categories/$categoryId": typeof DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute
"/dashboard/catalog/emex/$catalogCode/$vehicleId/": typeof DashboardCatalogEmexCatalogCodeVehicleIdIndexRoute
"/dashboard/catalog/pcat/$catalogId/$modelId/": typeof DashboardCatalogPcatCatalogIdModelIdIndexRoute
"/dashboard/catalog/emex/$catalogCode/$vehicleId/groups/$groupId": typeof DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute
"/dashboard/catalog/pcat/$catalogId/$modelId/$carId/": typeof DashboardCatalogPcatCatalogIdModelIdCarIdIndexRoute
"/dashboard/catalog/pcat/$catalogId/$modelId/$carId/groups/$groupId": typeof DashboardCatalogPcatCatalogIdModelIdCarIdGroupsGroupIdRoute
}
export interface FileRoutesByTo {
"/": typeof IndexRoute
@@ -281,6 +344,7 @@ export interface FileRoutesByTo {
"/dashboard/billing": typeof DashboardBillingRoute
"/dashboard/history": typeof DashboardHistoryRoute
"/dashboard/search": typeof DashboardSearchRoute
"/dashboard/service-test": typeof DashboardServiceTestRoute
"/dashboard/settings": typeof DashboardSettingsRoute
"/dashboard": typeof DashboardIndexRoute
"/dashboard/admin/analytics": typeof DashboardAdminAnalyticsRoute
@@ -296,7 +360,14 @@ 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/pcat/$catalogId": typeof DashboardCatalogPcatCatalogIdIndexRoute
"/dashboard/catalog/$brandName/$modelId/categories/$categoryId": typeof DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute
"/dashboard/catalog/emex/$catalogCode/$vehicleId": typeof DashboardCatalogEmexCatalogCodeVehicleIdIndexRoute
"/dashboard/catalog/pcat/$catalogId/$modelId": typeof DashboardCatalogPcatCatalogIdModelIdIndexRoute
"/dashboard/catalog/emex/$catalogCode/$vehicleId/groups/$groupId": typeof DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute
"/dashboard/catalog/pcat/$catalogId/$modelId/$carId": typeof DashboardCatalogPcatCatalogIdModelIdCarIdIndexRoute
"/dashboard/catalog/pcat/$catalogId/$modelId/$carId/groups/$groupId": typeof DashboardCatalogPcatCatalogIdModelIdCarIdGroupsGroupIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -319,6 +390,7 @@ export interface FileRoutesById {
"/dashboard/billing": typeof DashboardBillingRoute
"/dashboard/history": typeof DashboardHistoryRoute
"/dashboard/search": typeof DashboardSearchRoute
"/dashboard/service-test": typeof DashboardServiceTestRoute
"/dashboard/settings": typeof DashboardSettingsRoute
"/dashboard/": typeof DashboardIndexRoute
"/dashboard/admin/analytics": typeof DashboardAdminAnalyticsRoute
@@ -334,7 +406,14 @@ 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_/pcat/$catalogId/": typeof DashboardCatalogPcatCatalogIdIndexRoute
"/dashboard/catalog_/$brandName_/$modelId/categories_/$categoryId": typeof DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute
"/dashboard/catalog_/emex/$catalogCode_/$vehicleId/": typeof DashboardCatalogEmexCatalogCodeVehicleIdIndexRoute
"/dashboard/catalog_/pcat/$catalogId_/$modelId/": typeof DashboardCatalogPcatCatalogIdModelIdIndexRoute
"/dashboard/catalog_/emex/$catalogCode_/$vehicleId_/groups/$groupId": typeof DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute
"/dashboard/catalog_/pcat/$catalogId_/$modelId_/$carId/": typeof DashboardCatalogPcatCatalogIdModelIdCarIdIndexRoute
"/dashboard/catalog_/pcat/$catalogId_/$modelId_/$carId_/groups/$groupId": typeof DashboardCatalogPcatCatalogIdModelIdCarIdGroupsGroupIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -357,6 +436,7 @@ export interface FileRouteTypes {
| "/dashboard/billing"
| "/dashboard/history"
| "/dashboard/search"
| "/dashboard/service-test"
| "/dashboard/settings"
| "/dashboard/"
| "/dashboard/admin/analytics"
@@ -372,7 +452,14 @@ export interface FileRouteTypes {
| "/dashboard/vehicles/$id/"
| "/dashboard/vehicles/$id/categories/$categoryId"
| "/dashboard/catalog/$brandName/$modelId/"
| "/dashboard/catalog/emex/$catalogCode/"
| "/dashboard/catalog/pcat/$catalogId/"
| "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
| "/dashboard/catalog/emex/$catalogCode/$vehicleId/"
| "/dashboard/catalog/pcat/$catalogId/$modelId/"
| "/dashboard/catalog/emex/$catalogCode/$vehicleId/groups/$groupId"
| "/dashboard/catalog/pcat/$catalogId/$modelId/$carId/"
| "/dashboard/catalog/pcat/$catalogId/$modelId/$carId/groups/$groupId"
fileRoutesByTo: FileRoutesByTo
to:
| "/"
@@ -392,6 +479,7 @@ export interface FileRouteTypes {
| "/dashboard/billing"
| "/dashboard/history"
| "/dashboard/search"
| "/dashboard/service-test"
| "/dashboard/settings"
| "/dashboard"
| "/dashboard/admin/analytics"
@@ -407,7 +495,14 @@ export interface FileRouteTypes {
| "/dashboard/vehicles/$id"
| "/dashboard/vehicles/$id/categories/$categoryId"
| "/dashboard/catalog/$brandName/$modelId"
| "/dashboard/catalog/emex/$catalogCode"
| "/dashboard/catalog/pcat/$catalogId"
| "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
| "/dashboard/catalog/emex/$catalogCode/$vehicleId"
| "/dashboard/catalog/pcat/$catalogId/$modelId"
| "/dashboard/catalog/emex/$catalogCode/$vehicleId/groups/$groupId"
| "/dashboard/catalog/pcat/$catalogId/$modelId/$carId"
| "/dashboard/catalog/pcat/$catalogId/$modelId/$carId/groups/$groupId"
id:
| "__root__"
| "/"
@@ -429,6 +524,7 @@ export interface FileRouteTypes {
| "/dashboard/billing"
| "/dashboard/history"
| "/dashboard/search"
| "/dashboard/service-test"
| "/dashboard/settings"
| "/dashboard/"
| "/dashboard/admin/analytics"
@@ -444,7 +540,14 @@ export interface FileRouteTypes {
| "/dashboard/vehicles_/$id/"
| "/dashboard/vehicles_/$id/categories_/$categoryId"
| "/dashboard/catalog_/$brandName_/$modelId/"
| "/dashboard/catalog_/emex/$catalogCode/"
| "/dashboard/catalog_/pcat/$catalogId/"
| "/dashboard/catalog_/$brandName_/$modelId/categories_/$categoryId"
| "/dashboard/catalog_/emex/$catalogCode_/$vehicleId/"
| "/dashboard/catalog_/pcat/$catalogId_/$modelId/"
| "/dashboard/catalog_/emex/$catalogCode_/$vehicleId_/groups/$groupId"
| "/dashboard/catalog_/pcat/$catalogId_/$modelId_/$carId/"
| "/dashboard/catalog_/pcat/$catalogId_/$modelId_/$carId_/groups/$groupId"
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -555,6 +658,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DashboardSettingsRouteImport
parentRoute: typeof DashboardRoute
}
"/dashboard/service-test": {
id: "/dashboard/service-test"
path: "/service-test"
fullPath: "/dashboard/service-test"
preLoaderRoute: typeof DashboardServiceTestRouteImport
parentRoute: typeof DashboardRoute
}
"/dashboard/search": {
id: "/dashboard/search"
path: "/search"
@@ -688,6 +798,20 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DashboardCatalogBrandNameIndexRouteImport
parentRoute: typeof DashboardRoute
}
"/dashboard/catalog_/pcat/$catalogId/": {
id: "/dashboard/catalog_/pcat/$catalogId/"
path: "/catalog/pcat/$catalogId"
fullPath: "/dashboard/catalog/pcat/$catalogId/"
preLoaderRoute: typeof DashboardCatalogPcatCatalogIdIndexRouteImport
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 +826,20 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DashboardVehiclesIdCategoriesCategoryIdRouteImport
parentRoute: typeof DashboardRoute
}
"/dashboard/catalog_/pcat/$catalogId_/$modelId/": {
id: "/dashboard/catalog_/pcat/$catalogId_/$modelId/"
path: "/catalog/pcat/$catalogId/$modelId"
fullPath: "/dashboard/catalog/pcat/$catalogId/$modelId/"
preLoaderRoute: typeof DashboardCatalogPcatCatalogIdModelIdIndexRouteImport
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 +847,27 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DashboardCatalogBrandNameModelIdCategoriesCategoryIdRouteImport
parentRoute: typeof DashboardRoute
}
"/dashboard/catalog_/pcat/$catalogId_/$modelId_/$carId/": {
id: "/dashboard/catalog_/pcat/$catalogId_/$modelId_/$carId/"
path: "/catalog/pcat/$catalogId/$modelId/$carId"
fullPath: "/dashboard/catalog/pcat/$catalogId/$modelId/$carId/"
preLoaderRoute: typeof DashboardCatalogPcatCatalogIdModelIdCarIdIndexRouteImport
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
}
"/dashboard/catalog_/pcat/$catalogId_/$modelId_/$carId_/groups/$groupId": {
id: "/dashboard/catalog_/pcat/$catalogId_/$modelId_/$carId_/groups/$groupId"
path: "/catalog/pcat/$catalogId/$modelId/$carId/groups/$groupId"
fullPath: "/dashboard/catalog/pcat/$catalogId/$modelId/$carId/groups/$groupId"
preLoaderRoute: typeof DashboardCatalogPcatCatalogIdModelIdCarIdGroupsGroupIdRouteImport
parentRoute: typeof DashboardRoute
}
}
}
@@ -732,6 +891,7 @@ interface DashboardRouteChildren {
DashboardBillingRoute: typeof DashboardBillingRoute
DashboardHistoryRoute: typeof DashboardHistoryRoute
DashboardSearchRoute: typeof DashboardSearchRoute
DashboardServiceTestRoute: typeof DashboardServiceTestRoute
DashboardSettingsRoute: typeof DashboardSettingsRoute
DashboardIndexRoute: typeof DashboardIndexRoute
DashboardAdminAnalyticsRoute: typeof DashboardAdminAnalyticsRoute
@@ -747,13 +907,21 @@ interface DashboardRouteChildren {
DashboardVehiclesIdIndexRoute: typeof DashboardVehiclesIdIndexRoute
DashboardVehiclesIdCategoriesCategoryIdRoute: typeof DashboardVehiclesIdCategoriesCategoryIdRoute
DashboardCatalogBrandNameModelIdIndexRoute: typeof DashboardCatalogBrandNameModelIdIndexRoute
DashboardCatalogEmexCatalogCodeIndexRoute: typeof DashboardCatalogEmexCatalogCodeIndexRoute
DashboardCatalogPcatCatalogIdIndexRoute: typeof DashboardCatalogPcatCatalogIdIndexRoute
DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute: typeof DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute
DashboardCatalogEmexCatalogCodeVehicleIdIndexRoute: typeof DashboardCatalogEmexCatalogCodeVehicleIdIndexRoute
DashboardCatalogPcatCatalogIdModelIdIndexRoute: typeof DashboardCatalogPcatCatalogIdModelIdIndexRoute
DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute: typeof DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute
DashboardCatalogPcatCatalogIdModelIdCarIdIndexRoute: typeof DashboardCatalogPcatCatalogIdModelIdCarIdIndexRoute
DashboardCatalogPcatCatalogIdModelIdCarIdGroupsGroupIdRoute: typeof DashboardCatalogPcatCatalogIdModelIdCarIdGroupsGroupIdRoute
}
const DashboardRouteChildren: DashboardRouteChildren = {
DashboardBillingRoute: DashboardBillingRoute,
DashboardHistoryRoute: DashboardHistoryRoute,
DashboardSearchRoute: DashboardSearchRoute,
DashboardServiceTestRoute: DashboardServiceTestRoute,
DashboardSettingsRoute: DashboardSettingsRoute,
DashboardIndexRoute: DashboardIndexRoute,
DashboardAdminAnalyticsRoute: DashboardAdminAnalyticsRoute,
@@ -771,8 +939,22 @@ const DashboardRouteChildren: DashboardRouteChildren = {
DashboardVehiclesIdCategoriesCategoryIdRoute,
DashboardCatalogBrandNameModelIdIndexRoute:
DashboardCatalogBrandNameModelIdIndexRoute,
DashboardCatalogEmexCatalogCodeIndexRoute:
DashboardCatalogEmexCatalogCodeIndexRoute,
DashboardCatalogPcatCatalogIdIndexRoute:
DashboardCatalogPcatCatalogIdIndexRoute,
DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute:
DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute,
DashboardCatalogEmexCatalogCodeVehicleIdIndexRoute:
DashboardCatalogEmexCatalogCodeVehicleIdIndexRoute,
DashboardCatalogPcatCatalogIdModelIdIndexRoute:
DashboardCatalogPcatCatalogIdModelIdIndexRoute,
DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute:
DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute,
DashboardCatalogPcatCatalogIdModelIdCarIdIndexRoute:
DashboardCatalogPcatCatalogIdModelIdCarIdIndexRoute,
DashboardCatalogPcatCatalogIdModelIdCarIdGroupsGroupIdRoute:
DashboardCatalogPcatCatalogIdModelIdCarIdGroupsGroupIdRoute,
}
const DashboardRouteWithChildren = DashboardRoute._addFileChildren(

View File

@@ -13,6 +13,7 @@ import {
Copy,
CreditCard,
DollarSign,
FlaskConical,
History,
LayoutDashboard,
Library,
@@ -69,6 +70,7 @@ const adminItems = [
{ to: "/dashboard/admin/analytics", label: "Analitik", icon: BarChart3 },
{ to: "/dashboard/admin/copy-logs", label: "OEM Kopyalama", icon: Copy },
{ to: "/dashboard/admin/referrals", label: "Referanslar", icon: Share2 },
{ to: "/dashboard/service-test", label: "Servis Test", icon: FlaskConical },
] as const;
// ─── HELPERS ──────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,307 @@
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { AlertCircle, ArrowLeft, Car, ChevronRight, RotateCcw } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode/")({
component: EmexVehicleListPage,
});
// ── Types ────────────────────────────────────────────
interface WizardRow {
name: string;
value: string | null;
determined: boolean;
options: WizardOption[];
}
interface WizardOption {
key: string;
value: string;
}
interface EmexVehicle {
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;
}
// ── Component ────────────────────────────────────────
function EmexVehicleListPage() {
const { t } = useTranslation();
const { catalogCode } = Route.useParams();
// Current SSD state for wizard navigation
const [ssd, setSsd] = useState("");
// Fetch wizard data for current SSD
const {
data: wizardRows,
isLoading: wizardLoading,
isError: wizardError,
} = useQuery({
queryKey: ["emex-wizard", catalogCode, ssd],
queryFn: () =>
api.get<WizardRow[]>(
`/catalog/emex/brands/${catalogCode}/wizard?ssd=${encodeURIComponent(ssd)}`,
),
});
// Parse wizard state
const determined = useMemo(() => wizardRows?.filter((r) => r.determined) ?? [], [wizardRows]);
const undetermined = useMemo(
() => wizardRows?.filter((r) => !r.determined && r.options?.length > 0) ?? [],
[wizardRows],
);
const allDetermined = wizardRows ? wizardRows.length > 0 && undetermined.length === 0 : false;
// Get the "Sales Designation" and "Model" from determined params
const wizardMatch = useMemo(() => {
if (!allDetermined || !determined.length) return null;
let salesDesignation: string | null = null;
let model: string | null = null;
for (const key of ["Sales Designation", "Name", "Modification", "Model name"]) {
const row = determined.find((r) => r.name === key);
if (row?.value && row.value !== "None") {
salesDesignation = row.value;
break;
}
}
const modelRow = determined.find((r) => r.name === "Model");
if (modelRow?.value && modelRow.value !== "None") {
model = modelRow.value;
}
const name = salesDesignation || model;
if (!name) return null;
return { name, model };
}, [allDetermined, determined]);
// When all wizard params are determined, search DB for matching vehicles
const { data: matchedVehicles, isLoading: matchLoading } = useQuery({
queryKey: ["emex-wizard-vehicles", catalogCode, wizardMatch?.name, wizardMatch?.model],
queryFn: () => {
const params = new URLSearchParams({ name: wizardMatch?.name ?? "" });
if (wizardMatch?.model) params.set("model", wizardMatch?.model);
return api.get<EmexVehicle[]>(
`/catalog/emex/brands/${catalogCode}/wizard-vehicles?${params}`,
);
},
enabled: !!wizardMatch,
});
// Handle wizard option selection — navigate to new SSD
const handleSelect = useCallback((_rowName: string, option: WizardOption) => {
setSsd(option.key);
}, []);
// Reset wizard to initial state
const handleReset = useCallback(() => {
setSsd("");
}, []);
return (
<div className="space-y-4">
{/* Header */}
<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>
{determined.length > 0 && (
<Button variant="ghost" size="sm" onClick={handleReset}>
<RotateCcw className="mr-1 size-3.5" />
{t("catalog.resetSelection")}
</Button>
)}
</div>
{/* Determined params — shown as tags */}
{determined.length > 0 && (
<div className="flex flex-wrap gap-2">
{determined.map((r) => (
<span
key={r.name}
className="rounded-md border border-border bg-muted/50 px-2 py-1 text-xs"
>
<span className="text-muted-foreground">{r.name}:</span> {r.value}
</span>
))}
</div>
)}
{/* Error state */}
{wizardError && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
<AlertCircle className="size-4 shrink-0" />
<p>Katalog verileri yüklenemedi. Lütfen tekrar deneyin.</p>
</div>
)}
{/* Loading */}
{wizardLoading && (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" />
))}
</div>
)}
{/* Wizard: show first undetermined row as clickable list */}
{!wizardLoading && !wizardError && !allDetermined && undetermined.length > 0 && (
<WizardStep row={undetermined[0]} onSelect={handleSelect} />
)}
{/* All determined: show matched vehicles from DB */}
{allDetermined && (
<div className="space-y-3">
{matchLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : matchedVehicles && matchedVehicles.length > 0 ? (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{matchedVehicles.length} varyant</p>
{matchedVehicles.map((v) => (
<VehicleRow key={v.id} vehicle={v} catalogCode={catalogCode} />
))}
</div>
) : (
<div className="rounded-lg border border-dashed border-border py-8 text-center">
<Car className="mx-auto mb-2 size-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">
Bu araç konfigürasyonu için parça verisi henüz mevcut değil.
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
Farklı bir model veya varyant seçmeyi deneyin.
</p>
</div>
)}
</div>
)}
{/* Empty state: no wizard rows at all */}
{!wizardLoading && !wizardError && wizardRows && wizardRows.length === 0 && (
<p className="py-8 text-center text-muted-foreground">{t("catalog.noModels")}</p>
)}
</div>
);
}
// ── Wizard step: show options for first undetermined row ──
function WizardStep({
row,
onSelect,
}: {
row: WizardRow;
onSelect: (rowName: string, option: WizardOption) => void;
}) {
const [search, setSearch] = useState("");
const filtered = useMemo(() => {
if (!search) return row.options;
const q = search.toLowerCase();
return row.options.filter((o) => o.value.toLowerCase().includes(q));
}, [row.options, search]);
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium">{row.name}</h2>
<span className="text-xs text-muted-foreground">{row.options.length} seçenek</span>
</div>
{row.options.length > 10 && (
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Ara..."
className="h-8 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
/>
)}
<div className="grid gap-1">
{filtered.map((opt) => (
<button
key={opt.key}
type="button"
onClick={() => onSelect(row.name, opt)}
className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-left text-sm transition-colors hover:bg-accent"
>
<span className="min-w-0 flex-1 truncate">{opt.value}</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</button>
))}
{filtered.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">Sonuç bulunamadı</p>
)}
</div>
</div>
);
}
// ── Vehicle row ──────────────────────────────────────
function VehicleRow({
vehicle,
catalogCode,
}: {
vehicle: EmexVehicle;
catalogCode: string;
}) {
const parts: string[] = [];
if (vehicle.optionsRaw) {
parts.push(vehicle.optionsRaw);
}
const raw = vehicle.optionsRaw?.toLowerCase() || "";
if (vehicle.engine && !raw.includes(vehicle.engine.toLowerCase())) parts.push(vehicle.engine);
if (vehicle.bodyType && !raw.includes(vehicle.bodyType.toLowerCase()))
parts.push(vehicle.bodyType);
if (vehicle.transmission && !raw.includes(vehicle.transmission.toLowerCase()))
parts.push(vehicle.transmission);
if (vehicle.driveType && !raw.includes(vehicle.driveType.toLowerCase()))
parts.push(vehicle.driveType);
if (parts.length === 0 && vehicle.engine) parts.push(vehicle.engine);
return (
<Link
to="/dashboard/catalog/emex/$catalogCode/$vehicleId"
params={{ catalogCode, vehicleId: vehicle.id }}
className="flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2.5 transition-colors hover:bg-accent"
>
<Car className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
{parts.length > 0 ? (
<p className="truncate text-sm">{parts.join(" · ")}</p>
) : (
<p className="text-sm text-muted-foreground">{vehicle.vehicleId}</p>
)}
</div>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</Link>
);
}

View File

@@ -0,0 +1,190 @@
import { api } from "@/lib/api-client";
import { EMEX_GROUP_HIERARCHY } from "@/lib/emex-group-hierarchy";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, ChevronDown, ChevronRight, FolderOpen } from "lucide-react";
import { useState } from "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;
}
interface TreeNode {
label: string;
children: TreeNode[];
groups: EmexGroup[];
}
function buildTree(groups: EmexGroup[]): TreeNode[] {
const root: TreeNode = { label: "", children: [], groups: [] };
for (const g of groups) {
const path = EMEX_GROUP_HIERARCHY[g.groupId];
if (!path || path.length === 0) {
// Unmapped group — put under root
root.groups.push(g);
continue;
}
let current = root;
for (const segment of path) {
let child = current.children.find((c) => c.label === segment);
if (!child) {
child = { label: segment, children: [], groups: [] };
current.children.push(child);
}
current = child;
}
current.groups.push(g);
}
// If no tree structure was built (no mappings matched), return flat
if (root.children.length === 0 && root.groups.length > 0) {
return [];
}
return root.children;
}
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`),
});
const tree = groups ? buildTree(groups) : [];
const isFlat = tree.length === 0 && groups && groups.length > 0;
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>
{groups && <span className="text-sm text-muted-foreground">({groups.length})</span>}
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" />
))}
</div>
) : !groups || groups.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">{t("catalog.noCategories")}</p>
) : isFlat ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{groups.map((g) => (
<GroupLink key={g.id} group={g} catalogCode={catalogCode} vehicleId={vehicleId} />
))}
</div>
) : (
<div className="space-y-1">
{tree.map((node) => (
<TreeSection
key={node.label}
node={node}
catalogCode={catalogCode}
vehicleId={vehicleId}
depth={0}
/>
))}
</div>
)}
</div>
);
}
function TreeSection({
node,
catalogCode,
vehicleId,
depth,
}: {
node: TreeNode;
catalogCode: string;
vehicleId: string;
depth: number;
}) {
const [open, setOpen] = useState(depth === 0);
const totalGroups = countGroups(node);
return (
<div style={{ marginLeft: depth > 0 ? 16 : 0 }}>
<button
type="button"
onClick={() => setOpen(!open)}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors hover:bg-accent"
>
{open ? (
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="flex-1">{node.label}</span>
<span className="text-xs text-muted-foreground">{totalGroups}</span>
</button>
{open && (
<div className="ml-2 border-l border-border pl-2">
{node.children.map((child) => (
<TreeSection
key={child.label}
node={child}
catalogCode={catalogCode}
vehicleId={vehicleId}
depth={depth + 1}
/>
))}
{node.groups.map((g) => (
<GroupLink key={g.id} group={g} catalogCode={catalogCode} vehicleId={vehicleId} />
))}
</div>
)}
</div>
);
}
function GroupLink({
group,
catalogCode,
vehicleId,
}: {
group: EmexGroup;
catalogCode: string;
vehicleId: string;
}) {
return (
<Link
to="/dashboard/catalog/emex/$catalogCode/$vehicleId/groups/$groupId"
params={{ catalogCode, vehicleId, groupId: group.id }}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors hover:bg-accent"
>
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{group.name}</span>
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
</Link>
);
}
function countGroups(node: TreeNode): number {
return node.groups.length + node.children.reduce((sum, c) => sum + countGroups(c), 0);
}

View File

@@ -0,0 +1,118 @@
import type { Part, SchemaPic } from "@/hooks/use-parts";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { useSchemaStore } from "@/stores/schema.store";
import { Button, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
import { Suspense, lazy, useEffect, useState } from "react";
const SchemaViewer = lazy(() =>
import("@/components/schema/schema-viewer").then((mod) => ({
default: mod.SchemaViewer,
})),
);
export const Route = createFileRoute(
"/dashboard/catalog_/emex/$catalogCode_/$vehicleId_/groups/$groupId",
)({
component: EmexGroupPartsPage,
});
interface EmexGroupParts {
group: {
id: string;
groupId: string;
name: string;
nameOriginal: string | null;
};
parts: Part[];
schemaPics: SchemaPic[];
}
function EmexGroupPartsPage() {
const { t } = useTranslation();
const { catalogCode, vehicleId, groupId } = Route.useParams();
const [activeImageIndex, setActiveImageIndex] = useState(0);
const { data, isLoading } = useQuery({
queryKey: ["emex-group-parts", vehicleId, groupId],
queryFn: () => api.get<EmexGroupParts>(`/catalog/emex/vehicles/${vehicleId}/groups/${groupId}`),
});
// Reset schema store and image index on group change
useEffect(() => {
useSchemaStore.getState().resetView();
setActiveImageIndex(0);
}, [groupId]);
const activePic = data?.schemaPics?.[activeImageIndex] ?? null;
const totalImages = data?.schemaPics?.length ?? 0;
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>
<Suspense
fallback={
<div className="flex flex-col gap-4 rounded-lg border border-border md:h-[600px] md:flex-row">
<div className="flex h-[300px] items-center justify-center md:h-auto md:w-[60%]">
<Skeleton className="h-[80%] w-[80%]" />
</div>
<div className="w-full space-y-3 p-4 md:w-[40%]">
<Skeleton className="h-6 w-1/2" />
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</div>
}
>
<SchemaViewer
schemaPic={activePic}
hotspots={[]}
parts={data?.parts ?? []}
isLoading={isLoading}
vehicleId={vehicleId}
categoryId={groupId}
/>
</Suspense>
{totalImages > 1 && (
<div className="flex items-center justify-center gap-2 py-1">
<Button
size="sm"
variant="ghost"
disabled={activeImageIndex === 0}
onClick={() => setActiveImageIndex((i) => i - 1)}
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-sm text-muted-foreground">
{activeImageIndex + 1} / {totalImages}
</span>
<Button
size="sm"
variant="ghost"
disabled={activeImageIndex === totalImages - 1}
onClick={() => setActiveImageIndex((i) => i + 1)}
>
<ChevronRight className="size-4" />
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, Library } from "lucide-react";
export const Route = createFileRoute("/dashboard/catalog_/pcat/$catalogId/")({
component: PcatModelsPage,
});
interface PcatModel {
id: string;
catalogId: string;
name: string;
imgUrl: string | null;
yearFrom: number | null;
yearTo: number | null;
carsCount: number;
}
function PcatModelsPage() {
const { t } = useTranslation();
const { catalogId } = Route.useParams();
const { data: models, isLoading } = useQuery({
queryKey: ["pcat-models", catalogId],
queryFn: () => api.get<PcatModel[]>(`/catalog/pcat/catalogs/${catalogId}/models`),
});
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">{catalogId.toUpperCase()}</h1>
</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: 8 }).map((_, i) => (
<Skeleton key={i} className="h-28 w-full rounded-xl" />
))}
</div>
) : !models || models.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">{t("catalog.noModels")}</p>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{models.map((model) => (
<Link
key={model.id}
to="/dashboard/catalog/pcat/$catalogId/$modelId"
params={{ catalogId, modelId: model.id }}
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"
>
{model.imgUrl ? (
<img
src={model.imgUrl.startsWith("//") ? `https:${model.imgUrl}` : model.imgUrl}
alt={model.name}
className="mb-2 h-16 w-auto object-contain"
/>
) : (
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-emerald-500/10">
<Library className="size-5 text-emerald-500" />
</div>
)}
<p className="text-sm font-semibold">{model.name}</p>
{(model.yearFrom || model.yearTo) && (
<p className="mt-0.5 text-xs text-muted-foreground">
{model.yearFrom || "?"} - {model.yearTo || "..."}
</p>
)}
{model.carsCount > 0 && (
<p className="mt-0.5 text-xs text-muted-foreground">{model.carsCount} araç</p>
)}
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,123 @@
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, Car, ChevronRight } from "lucide-react";
import { useMemo, useState } from "react";
export const Route = createFileRoute("/dashboard/catalog_/pcat/$catalogId_/$modelId/")({
component: PcatCarsPage,
});
interface PcatCar {
id: string;
modelId: string;
name: string;
yearFrom: number | null;
yearTo: number | null;
engine: string | null;
transmission: string | null;
bodyType: string | null;
fuelType: string | null;
driveType: string | null;
steering: string | null;
schemasCount: number;
partsCount: number;
}
function PcatCarsPage() {
const { t } = useTranslation();
const { catalogId, modelId } = Route.useParams();
const [search, setSearch] = useState("");
const { data: cars, isLoading } = useQuery({
queryKey: ["pcat-cars", catalogId, modelId],
queryFn: () => api.get<PcatCar[]>(`/catalog/pcat/catalogs/${catalogId}/models/${modelId}/cars`),
});
const filtered = useMemo(() => {
if (!cars) return [];
if (!search) return cars;
const q = search.toLowerCase();
return cars.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.engine?.toLowerCase().includes(q) ||
c.bodyType?.toLowerCase().includes(q),
);
}, [cars, search]);
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Link to="/dashboard/catalog/pcat/$catalogId" params={{ catalogId }}>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-1 size-4" />
{t("catalog.backToModels")}
</Button>
</Link>
<h1 className="text-xl font-bold">{catalogId.toUpperCase()}</h1>
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))}
</div>
) : !cars || cars.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">{t("catalog.noModels")}</p>
) : (
<>
{cars.length > 10 && (
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Ara..."
className="h-8 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
/>
)}
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{filtered.length} araç</p>
{filtered.map((car) => (
<Link
key={car.id}
to="/dashboard/catalog/pcat/$catalogId/$modelId/$carId"
params={{ catalogId, modelId, carId: car.id }}
className="flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2.5 transition-colors hover:bg-accent"
>
<Car className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{car.name}</p>
<p className="truncate text-xs text-muted-foreground">
{[
car.engine,
car.bodyType,
car.transmission,
car.fuelType,
car.yearFrom && car.yearTo
? `${car.yearFrom}-${car.yearTo}`
: car.yearFrom
? `${car.yearFrom}+`
: null,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
{car.schemasCount > 0 && (
<span className="shrink-0 text-xs text-muted-foreground">
{car.schemasCount} şema
</span>
)}
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</Link>
))}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, ChevronRight, FolderOpen } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/dashboard/catalog_/pcat/$catalogId_/$modelId_/$carId/")({
component: PcatCarGroupsPage,
});
interface PcatGroup {
id: string;
catalogId: string;
parentId: string | null;
name: string;
imgUrl: string | null;
hasSubgroups: boolean;
hasParts: boolean;
}
function PcatCarGroupsPage() {
const { t } = useTranslation();
const { catalogId, modelId, carId } = Route.useParams();
const [parentStack, setParentStack] = useState<{ id: string; name: string }[]>([]);
const currentParentId =
parentStack.length > 0 ? parentStack[parentStack.length - 1].id : undefined;
const { data: groups, isLoading } = useQuery({
queryKey: ["pcat-car-groups", carId, currentParentId || "root"],
queryFn: () => {
const params = currentParentId ? `?parentId=${encodeURIComponent(currentParentId)}` : "";
return api.get<PcatGroup[]>(`/catalog/pcat/cars/${carId}/groups${params}`);
},
});
const handleGroupClick = (group: PcatGroup) => {
if (group.hasSubgroups) {
setParentStack((prev) => [...prev, { id: group.id, name: group.name }]);
}
};
const handleBack = () => {
setParentStack((prev) => prev.slice(0, -1));
};
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
{parentStack.length > 0 ? (
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="mr-1 size-4" />
{t("catalog.backToCategories")}
</Button>
) : (
<Link to="/dashboard/catalog/pcat/$catalogId/$modelId" params={{ catalogId, modelId }}>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-1 size-4" />
{t("catalog.backToModels")}
</Button>
</Link>
)}
<h1 className="text-xl font-bold">
{catalogId.toUpperCase()}
{parentStack.length > 0 && (
<span className="font-normal text-muted-foreground">
{" / "}
{parentStack.map((p) => p.name).join(" / ")}
</span>
)}
</h1>
</div>
{isLoading ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full rounded-xl" />
))}
</div>
) : !groups || groups.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">{t("catalog.noCategories")}</p>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{groups.map((group) => {
// Leaf group with parts → link to schema page
if (!group.hasSubgroups && group.hasParts) {
return (
<Link
key={group.id}
to="/dashboard/catalog/pcat/$catalogId/$modelId/$carId/groups/$groupId"
params={{ catalogId, modelId, carId, groupId: group.id }}
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"
>
{group.imgUrl ? (
<img
src={group.imgUrl.startsWith("//") ? `https:${group.imgUrl}` : group.imgUrl}
alt={group.name}
className="mb-2 h-12 w-auto object-contain"
/>
) : (
<FolderOpen className="mb-2 size-8 text-muted-foreground/50" />
)}
<p className="text-xs font-medium">{group.name}</p>
</Link>
);
}
// Group with subgroups → drill in
return (
<button
key={group.id}
type="button"
onClick={() => handleGroupClick(group)}
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"
>
{group.imgUrl ? (
<img
src={group.imgUrl.startsWith("//") ? `https:${group.imgUrl}` : group.imgUrl}
alt={group.name}
className="mb-2 h-12 w-auto object-contain"
/>
) : (
<FolderOpen className="mb-2 size-8 text-muted-foreground/50" />
)}
<p className="text-xs font-medium">{group.name}</p>
{group.hasSubgroups && (
<ChevronRight className="mt-1 size-3.5 text-muted-foreground" />
)}
</button>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,135 @@
import type { Hotspot, Part, SchemaPic } from "@/hooks/use-parts";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { useSchemaStore } from "@/stores/schema.store";
import { Button, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
import { Suspense, lazy, useEffect, useState } from "react";
const SchemaViewer = lazy(() =>
import("@/components/schema/schema-viewer").then((mod) => ({
default: mod.SchemaViewer,
})),
);
export const Route = createFileRoute(
"/dashboard/catalog_/pcat/$catalogId_/$modelId_/$carId_/groups/$groupId",
)({
component: PcatSchemaPage,
});
interface PcatSchemaImage {
id: string;
name: string | null;
imgUrl: string | null;
partsCount: number;
}
interface PcatSchemaDetail {
schemaImage: PcatSchemaImage;
parts: Part[];
schemaPics: SchemaPic[];
hotspots: Hotspot[];
}
function PcatSchemaPage() {
const { t } = useTranslation();
const { catalogId, modelId, carId, groupId } = Route.useParams();
const [activeImageIndex, setActiveImageIndex] = useState(0);
// Fetch all schema images for this car+group
const { data: schemaImages, isLoading: imagesLoading } = useQuery({
queryKey: ["pcat-schemas", carId, groupId],
queryFn: () =>
api.get<PcatSchemaImage[]>(`/catalog/pcat/cars/${carId}/groups/${groupId}/schemas`),
});
const activeSchema = schemaImages?.[activeImageIndex];
const totalImages = schemaImages?.length ?? 0;
// Fetch detail for active schema image
const { data: detail, isLoading: detailLoading } = useQuery({
queryKey: ["pcat-schema-detail", activeSchema?.id],
queryFn: () => api.get<PcatSchemaDetail>(`/catalog/pcat/schemas/${activeSchema?.id}`),
enabled: !!activeSchema?.id,
});
// Reset schema store on group change
useEffect(() => {
useSchemaStore.getState().resetView();
setActiveImageIndex(0);
}, [groupId]);
const isLoading = imagesLoading || detailLoading;
const activePic = detail?.schemaPics?.[0] ?? null;
const hotspots = detail?.hotspots ?? [];
const parts = detail?.parts ?? [];
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Link
to="/dashboard/catalog/pcat/$catalogId/$modelId/$carId"
params={{ catalogId, modelId, carId }}
>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-1 size-4" />
{t("catalog.backToCategories")}
</Button>
</Link>
<h1 className="text-xl font-bold">{activeSchema?.name || t("catalog.parts")}</h1>
</div>
<Suspense
fallback={
<div className="flex flex-col gap-4 rounded-lg border border-border md:h-[600px] md:flex-row">
<div className="flex h-[300px] items-center justify-center md:h-auto md:w-[60%]">
<Skeleton className="h-[80%] w-[80%]" />
</div>
<div className="w-full space-y-3 p-4 md:w-[40%]">
<Skeleton className="h-6 w-1/2" />
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</div>
}
>
<SchemaViewer
schemaPic={activePic}
hotspots={hotspots}
parts={parts}
isLoading={isLoading}
vehicleId={carId}
categoryId={groupId}
/>
</Suspense>
{totalImages > 1 && (
<div className="flex items-center justify-center gap-2 py-1">
<Button
size="sm"
variant="ghost"
disabled={activeImageIndex === 0}
onClick={() => setActiveImageIndex((i) => i - 1)}
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-sm text-muted-foreground">
{activeImageIndex + 1} / {totalImages}
</span>
<Button
size="sm"
variant="ghost"
disabled={activeImageIndex === totalImages - 1}
onClick={() => setActiveImageIndex((i) => i + 1)}
>
<ChevronRight className="size-4" />
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,403 @@
import { VehicleSelectModal } from "@/components/vehicles/vehicle-select-modal";
import { ApiError, api } from "@/lib/api-client";
import { toast } from "@/lib/toast";
import { Badge, Button, Input, Separator } from "@sase/ui";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertCircle, Car, Check, Copy, FlaskConical, Loader2, Search } from "lucide-react";
import { useEffect, useRef, useState } from "react";
// ─── HELPERS ──────────────────────────────────────────────────────────────────
const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/;
function isValidVin(vin: string): boolean {
if (!vin || vin.length !== 17) return false;
return VIN_REGEX.test(vin.toUpperCase());
}
function sanitizeVin(raw: string): { cleaned: string; corrections: string[] } {
const corrections: string[] = [];
const cleaned = raw.replace(/[IOQioq]/g, (ch) => {
const upper = ch.toUpperCase();
if (upper === "I") {
corrections.push("I→1");
return "1";
}
if (upper === "O") {
corrections.push("O→0");
return "0";
}
corrections.push("Q→9");
return "9";
});
return { cleaned, corrections };
}
const SERVICE_OPTIONS = [
{ value: "all", label: "Normal Akış (Cascade)" },
{ value: "corgi", label: "Corgi (Offline WMI)" },
{ value: "parts-catalogs", label: "PartsCatalogs" },
{ value: "pl24", label: "PL24 (PartsLink24)" },
{ value: "emex", label: "EMEX" },
{ value: "vin-api", label: "VIN API (NHTSA)" },
] as const;
// ─── ROUTE ────────────────────────────────────────────────────────────────────
export const Route = createFileRoute("/dashboard/service-test")({
component: ServiceTestPage,
});
function ServiceTestPage() {
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const [vin, setVin] = useState("");
const [service, setService] = useState("all");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{
service: string;
success: boolean;
responseTimeMs: number;
result: any;
error?: string;
} | null>(null);
const [copied, setCopied] = useState(false);
// EMEX candidate selection state
const [candidates, setCandidates] = useState<any[] | null>(null);
const [candidateVin, setCandidateVin] = useState("");
const [selectLoading, setSelectLoading] = useState(false);
useEffect(() => {
inputRef.current?.focus();
}, []);
function handleVinChange(raw: string) {
const upper = raw.toUpperCase();
const { cleaned, corrections } = sanitizeVin(upper);
setVin(cleaned);
setError(null);
if (corrections.length > 0) {
const unique = [...new Set(corrections)];
toast.info(`Otomatik düzeltildi: ${unique.join(", ")}`, {
description: "Şase numarasında I, O, Q harfleri kullanılamaz",
duration: 2500,
});
}
}
async function handleTest(e: React.FormEvent) {
e.preventDefault();
setError(null);
setResult(null);
setCopied(false);
const cleanVin = vin.toUpperCase().trim();
if (!isValidVin(cleanVin)) {
setError("Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.");
return;
}
setLoading(true);
try {
const data = await api.post<any>("/vehicles/service-test", {
vin: cleanVin,
service: service === "all" ? undefined : service,
});
// EMEX success with candidates → show selection modal
if (data.success && data.result?.type === "candidates" && data.result.candidates) {
setCandidateVin(cleanVin);
setCandidates(data.result.candidates);
setResult(data);
setLoading(false);
return;
}
// EMEX success with single vehicle → navigate to decode flow
if (data.success && data.result?.type === "vehicle" && data.result.vehicle) {
setResult(data);
setLoading(false);
// Auto-decode via normal flow
try {
const decoded = await api.post<any>("/vehicles/decode", { vin: cleanVin });
if (decoded.id) {
navigate({ to: "/dashboard/vehicles/$id", params: { id: decoded.id } });
return;
}
} catch {
// Fall through to show JSON result
}
return;
}
setResult(data);
} catch (err) {
const message =
err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
setError(message);
toast.error("Servis testi başarısız");
} finally {
setLoading(false);
}
}
async function handleCopyJson() {
if (!result) return;
try {
await navigator.clipboard.writeText(JSON.stringify(result, null, 2));
setCopied(true);
toast.success("JSON kopyalandı");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Kopyalama başarısız");
}
}
async function handleCandidateSelect(carId: string) {
setSelectLoading(true);
try {
const data = await api.post<any>("/vehicles/decode", {
vin: candidateVin,
emexCarIndex: Number.parseInt(carId, 10),
});
setCandidates(null);
if (data.id) {
navigate({ to: "/dashboard/vehicles/$id", params: { id: data.id } });
}
} catch (err) {
const message = err instanceof ApiError ? err.message : "Bir hata oluştu.";
setError(message);
setCandidates(null);
toast.error("Araç seçimi başarısız");
} finally {
setSelectLoading(false);
}
}
function fillExampleVin() {
setVin("WVWZZZ1JZ3W597935");
inputRef.current?.focus();
}
return (
<div className="mx-auto max-w-3xl space-y-6">
{/* ─── SECTION 1: Hero Input Card ─────────────────────────────────── */}
<div className="rounded-2xl border border-border bg-background p-6 sm:p-8">
{/* Header */}
<div className="flex flex-col items-center text-center">
<div className="inline-flex size-14 items-center justify-center rounded-2xl bg-muted">
<FlaskConical className="size-6 text-muted-foreground" />
</div>
<h2 className="mt-4 font-[family-name:var(--font-display)] text-2xl font-bold tracking-tight">
Servis Test
</h2>
<p className="mt-1 text-sm text-muted-foreground">
VIN decode servislerini tek tek test edin
</p>
</div>
<Separator className="my-6 bg-border" />
{/* Form */}
<form onSubmit={handleTest} className="space-y-4">
{/* Service Selector */}
<div>
<label
htmlFor="service-select"
className="mb-1.5 block text-sm font-medium text-foreground"
>
Servis
</label>
<select
id="service-select"
value={service}
onChange={(e) => setService(e.target.value)}
className="h-12 w-full rounded-xl border border-border bg-muted/50 px-4 text-sm text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring"
>
{SERVICE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* VIN Input */}
<div className="relative">
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
placeholder="Şase numarasını girin (17 karakter)"
value={vin}
onChange={(e) => handleVinChange(e.target.value)}
maxLength={17}
className={`h-14 rounded-xl bg-muted/50 pl-12 font-mono tracking-wider ${vin.length === 0 ? "pr-24" : "pr-4"}`}
/>
{vin.length === 0 && (
<div className="pointer-events-none absolute right-4 top-1/2 flex -translate-y-1/2 items-center gap-1 text-xs text-muted-foreground">
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-[10px]">
Ctrl
</kbd>
<span>+</span>
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-[10px]">
K
</kbd>
</div>
)}
</div>
{/* 17-segment progress bar */}
<div className="flex gap-0.5">
{Array.from({ length: 17 }).map((_, i) => (
<div
key={i}
className={`h-1.5 flex-1 rounded-full transition-colors duration-200 ${
i < vin.length ? "bg-emerald-500" : "bg-muted"
}`}
/>
))}
</div>
{/* Counter + Example VIN */}
<div className="flex items-center justify-between text-sm">
<span className="tabular-nums text-muted-foreground">{vin.length}/17 karakter</span>
<button
type="button"
onClick={fillExampleVin}
className="text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
>
Örnek şase deneyin &rarr;
</button>
</div>
{/* Submit button */}
<Button
type="submit"
disabled={loading || vin.length !== 17}
className="h-12 w-full rounded-xl"
>
{loading ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<FlaskConical className="mr-2 size-4" />
)}
Test Et
</Button>
{/* Error card */}
{error && (
<div className="flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
<p className="text-sm text-destructive">{error}</p>
</div>
)}
</form>
</div>
{/* ─── SECTION 2: Vehicle Info Card (successful single decode) ──── */}
{result?.success && result.result?.type === "vehicle" && result.result.vehicle && (
<div className="rounded-2xl border border-emerald-500/30 bg-background p-5 sm:p-6">
<div className="flex items-start gap-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-emerald-500/10">
<Car className="size-5 text-emerald-500" />
</div>
<div className="min-w-0 flex-1">
<p className="font-[family-name:var(--font-display)] text-lg font-bold">
{result.result.vehicle.brand} {result.result.vehicle.model}
</p>
<p className="mt-0.5 text-sm text-muted-foreground">
{result.result.vehicle.year || "—"}
{result.result.vehicle.engineCode &&
` — Motor: ${result.result.vehicle.engineCode}`}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="default" className="bg-emerald-600 text-xs text-white">
Araç tanımlandı
</Badge>
<Badge variant="secondary" className="text-xs">
{result.service}
</Badge>
<Badge variant="secondary" className="text-xs">
{result.responseTimeMs.toLocaleString("tr-TR")}ms
</Badge>
</div>
</div>
</div>
{result.result.vehicle.categories?.length > 0 && (
<>
<Separator className="my-4 bg-border" />
<p className="mb-2 text-sm font-medium text-muted-foreground">
{result.result.vehicle.categories.length} kategori bulundu
</p>
</>
)}
</div>
)}
{/* ─── SECTION 3: Result Card (JSON for failures or non-vehicle results) ── */}
{result &&
(!result.success || result.result?.type !== "vehicle") &&
result.result?.type !== "candidates" && (
<div className="rounded-2xl border border-border bg-background p-5 sm:p-6">
{/* Meta badges */}
<div className="flex flex-wrap items-center gap-2">
<Badge variant="default" className="bg-blue-600 text-xs text-white">
{result.service}
</Badge>
<Badge variant="secondary" className="text-xs">
{result.responseTimeMs.toLocaleString("tr-TR")}ms
</Badge>
<Badge
variant={result.success ? "default" : "destructive"}
className={`text-xs ${result.success ? "bg-emerald-600 text-white" : ""}`}
>
{result.success ? "Başarılı" : "Başarısız"}
</Badge>
<div className="flex-1" />
<Button
variant="outline"
size="sm"
onClick={handleCopyJson}
className="h-8 gap-1.5 rounded-lg text-xs"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
{copied ? "Kopyalandı" : "JSON Kopyala"}
</Button>
</div>
{/* Error message */}
{result.error && (
<div className="mt-4 flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
<p className="text-sm text-destructive">{result.error}</p>
</div>
)}
{/* JSON output */}
{result.result !== null && (
<>
<Separator className="my-4 bg-border" />
<pre className="max-h-[600px] overflow-auto rounded-xl border border-border bg-muted/50 p-4 font-mono text-xs leading-relaxed">
{JSON.stringify(result.result, null, 2)}
</pre>
</>
)}
</div>
)}
{/* ─── Vehicle Selection Modal (EMEX multi-result) ──────────────── */}
{candidates && (
<VehicleSelectModal
open={!!candidates}
onClose={() => setCandidates(null)}
candidates={candidates}
vin={candidateVin}
onSelect={handleCandidateSelect}
loading={selectLoading}
/>
)}
</div>
);
}

File diff suppressed because one or more lines are too long