feat: pcat v2 — full catalog browsing with cars, groups, schema viewer
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
Expand pcat API with getCars, getCarGroups, getSchemaImages, getSchemaDetail endpoints. Add frontend routes for the complete flow: catalog → model → car → groups → schema+parts with hotspots. Reuses existing SchemaViewer component for interactive part diagrams. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,32 @@ export class PcatCatalogController {
|
||||
@Param("catalogId") catalogId: string,
|
||||
@Query("parentId") parentId?: string,
|
||||
) {
|
||||
return this.pcatCatalogService.getGroups(catalogId, parentId);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ 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 {
|
||||
@@ -14,6 +17,25 @@ export interface PcatModelDto {
|
||||
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 {
|
||||
@@ -26,10 +48,27 @@ export interface PcatGroupDto {
|
||||
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_TTL = {
|
||||
catalogs: 86400, // 24h
|
||||
models: 43200, // 12h
|
||||
groups: 21600, // 6h
|
||||
catalogs: 86400,
|
||||
models: 43200,
|
||||
cars: 21600,
|
||||
groups: 21600,
|
||||
schemas: 7200,
|
||||
schemaDetail: 7200,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -49,13 +88,19 @@ export class PcatCatalogService {
|
||||
const rows = await this.db.execute<{
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
img_url: string | null;
|
||||
models_count: number;
|
||||
}>(sql`SELECT id, name, models_count FROM pc.catalogs WHERE is_active = true ORDER BY name`);
|
||||
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);
|
||||
@@ -72,8 +117,11 @@ export class PcatCatalogService {
|
||||
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 FROM pc.models WHERE catalog_id = ${catalogId} AND is_active = true ORDER BY name`,
|
||||
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) => ({
|
||||
@@ -81,39 +129,81 @@ export class PcatCatalogService {
|
||||
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 getGroups(catalogId: string, parentId?: string): Promise<PcatGroupDto[]> {
|
||||
const cacheKey = `pcat:groups:${catalogId}:${parentId || "root"}`;
|
||||
async getCars(modelId: string): Promise<PcatCarDto[]> {
|
||||
const cacheKey = `pcat: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 = `pcat: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;
|
||||
id: string; catalog_id: string; parent_id: string | null; name: string; img_url: string | null; has_subgroups: boolean; has_parts: boolean;
|
||||
}>(
|
||||
sql`SELECT id, catalog_id, parent_id, name, img_url, has_subgroups, has_parts FROM pc.groups WHERE catalog_id = ${catalogId} AND parent_id = ${parentId} ORDER BY sort_order, name`,
|
||||
sql`SELECT DISTINCT g.id, g.catalog_id, g.parent_id, g.name, g.img_url, g.has_subgroups, g.has_parts
|
||||
FROM pc.car_groups cg JOIN pc.groups g ON g.id = cg.group_id
|
||||
WHERE cg.car_id = ${carId} AND 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;
|
||||
id: string; catalog_id: string; parent_id: string | null; name: string; img_url: string | null; has_subgroups: boolean; has_parts: boolean;
|
||||
}>(
|
||||
sql`SELECT id, catalog_id, parent_id, name, img_url, has_subgroups, has_parts FROM pc.groups WHERE catalog_id = ${catalogId} AND parent_id IS NULL ORDER BY sort_order, name`,
|
||||
sql`SELECT DISTINCT g.id, g.catalog_id, g.parent_id, g.name, g.img_url, g.has_subgroups, g.has_parts
|
||||
FROM pc.car_groups cg JOIN pc.groups g ON g.id = cg.group_id
|
||||
WHERE cg.car_id = ${carId} AND g.parent_id IS NULL
|
||||
ORDER BY g.name`,
|
||||
);
|
||||
|
||||
const result: PcatGroupDto[] = rows.map((r) => ({
|
||||
@@ -129,4 +219,119 @@ export class PcatCatalogService {
|
||||
await this.redis.setJson(cacheKey, result, CACHE_TTL.groups);
|
||||
return result;
|
||||
}
|
||||
|
||||
async getSchemaImages(carId: string, groupId: string): Promise<PcatSchemaImageDto[]> {
|
||||
const cacheKey = `pcat: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 group_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 = `pcat:schema-detail:${schemaImageId}`;
|
||||
const cached = await this.redis.getJson<PcatSchemaDetailDto>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const numId = 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 ? 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: 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: 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,9 @@ import { Route as DashboardVehiclesIdCategoriesCategoryIdRouteImport } from "./r
|
||||
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",
|
||||
@@ -261,12 +263,24 @@ const DashboardCatalogBrandNameModelIdCategoriesCategoryIdRoute =
|
||||
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
|
||||
@@ -309,6 +323,8 @@ export interface FileRoutesByFullPath {
|
||||
"/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
|
||||
@@ -350,6 +366,8 @@ export interface FileRoutesByTo {
|
||||
"/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
|
||||
@@ -394,6 +412,8 @@ export interface FileRoutesById {
|
||||
"/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
|
||||
@@ -438,6 +458,8 @@ export interface FileRouteTypes {
|
||||
| "/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:
|
||||
| "/"
|
||||
@@ -479,6 +501,8 @@ export interface FileRouteTypes {
|
||||
| "/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__"
|
||||
| "/"
|
||||
@@ -522,6 +546,8 @@ export interface FileRouteTypes {
|
||||
| "/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 {
|
||||
@@ -821,6 +847,13 @@ 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"
|
||||
@@ -828,6 +861,13 @@ declare module "@tanstack/react-router" {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -873,6 +913,8 @@ interface DashboardRouteChildren {
|
||||
DashboardCatalogEmexCatalogCodeVehicleIdIndexRoute: typeof DashboardCatalogEmexCatalogCodeVehicleIdIndexRoute
|
||||
DashboardCatalogPcatCatalogIdModelIdIndexRoute: typeof DashboardCatalogPcatCatalogIdModelIdIndexRoute
|
||||
DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute: typeof DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute
|
||||
DashboardCatalogPcatCatalogIdModelIdCarIdIndexRoute: typeof DashboardCatalogPcatCatalogIdModelIdCarIdIndexRoute
|
||||
DashboardCatalogPcatCatalogIdModelIdCarIdGroupsGroupIdRoute: typeof DashboardCatalogPcatCatalogIdModelIdCarIdGroupsGroupIdRoute
|
||||
}
|
||||
|
||||
const DashboardRouteChildren: DashboardRouteChildren = {
|
||||
@@ -909,6 +951,10 @@ const DashboardRouteChildren: DashboardRouteChildren = {
|
||||
DashboardCatalogPcatCatalogIdModelIdIndexRoute,
|
||||
DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute:
|
||||
DashboardCatalogEmexCatalogCodeVehicleIdGroupsGroupIdRoute,
|
||||
DashboardCatalogPcatCatalogIdModelIdCarIdIndexRoute:
|
||||
DashboardCatalogPcatCatalogIdModelIdCarIdIndexRoute,
|
||||
DashboardCatalogPcatCatalogIdModelIdCarIdGroupsGroupIdRoute:
|
||||
DashboardCatalogPcatCatalogIdModelIdCarIdGroupsGroupIdRoute,
|
||||
}
|
||||
|
||||
const DashboardRouteWithChildren = DashboardRoute._addFileChildren(
|
||||
|
||||
@@ -29,7 +29,10 @@ interface EmexBrand {
|
||||
interface PcatCatalog {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
imgUrl: string | null;
|
||||
modelsCount: number;
|
||||
carsCount: number;
|
||||
}
|
||||
|
||||
function CatalogBrandsPage() {
|
||||
@@ -194,15 +197,20 @@ function PL24BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||
}
|
||||
|
||||
function PcatCatalogCard({ catalog }: { catalog: PcatCatalog }) {
|
||||
const imgSrc = catalog.imgUrl?.startsWith("//") ? `https:${catalog.imgUrl}` : catalog.imgUrl;
|
||||
return (
|
||||
<Link
|
||||
to="/dashboard/catalog/pcat/$catalogId"
|
||||
params={{ catalogId: catalog.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"
|
||||
>
|
||||
<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>
|
||||
{imgSrc ? (
|
||||
<img src={imgSrc} alt={catalog.name} className="mb-2 h-10 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">{catalog.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{catalog.modelsCount} model
|
||||
|
||||
@@ -16,6 +16,9 @@ interface PcatModel {
|
||||
catalogId: string;
|
||||
name: string;
|
||||
imgUrl: string | null;
|
||||
yearFrom: number | null;
|
||||
yearTo: number | null;
|
||||
carsCount: number;
|
||||
}
|
||||
|
||||
function PcatModelsPage() {
|
||||
@@ -75,6 +78,16 @@ function PcatModelsPage() {
|
||||
</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>
|
||||
|
||||
@@ -1,131 +1,134 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft, ChevronRight, FolderOpen } from "lucide-react";
|
||||
import { ArrowLeft, Car, ChevronRight } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/dashboard/catalog_/pcat/$catalogId_/$modelId/",
|
||||
)({
|
||||
component: PcatGroupsPage,
|
||||
component: PcatCarsPage,
|
||||
});
|
||||
|
||||
interface PcatGroup {
|
||||
interface PcatCar {
|
||||
id: string;
|
||||
catalogId: string;
|
||||
parentId: string | null;
|
||||
modelId: string;
|
||||
name: string;
|
||||
imgUrl: string | null;
|
||||
hasSubgroups: boolean;
|
||||
hasParts: boolean;
|
||||
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 PcatGroupsPage() {
|
||||
function PcatCarsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { catalogId, modelId } = Route.useParams();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Navigation stack for drilling into subgroups
|
||||
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-groups", catalogId, currentParentId || "root"],
|
||||
queryFn: () => {
|
||||
const params = currentParentId
|
||||
? `?parentId=${encodeURIComponent(currentParentId)}`
|
||||
: "";
|
||||
return api.get<PcatGroup[]>(
|
||||
`/catalog/pcat/catalogs/${catalogId}/groups${params}`,
|
||||
);
|
||||
},
|
||||
const { data: cars, isLoading } = useQuery({
|
||||
queryKey: ["pcat-cars", catalogId, modelId],
|
||||
queryFn: () =>
|
||||
api.get<PcatCar[]>(
|
||||
`/catalog/pcat/catalogs/${catalogId}/models/${modelId}/cars`,
|
||||
),
|
||||
});
|
||||
|
||||
const handleGroupClick = (group: PcatGroup) => {
|
||||
if (group.hasSubgroups) {
|
||||
setParentStack((prev) => [...prev, { id: group.id, name: group.name }]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setParentStack((prev) => prev.slice(0, -1));
|
||||
};
|
||||
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">
|
||||
{parentStack.length > 0 ? (
|
||||
<Button variant="ghost" size="sm" onClick={handleBack}>
|
||||
<Link
|
||||
to="/dashboard/catalog/pcat/$catalogId"
|
||||
params={{ catalogId }}
|
||||
>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
{t("catalog.backToCategories")}
|
||||
{t("catalog.backToModels")}
|
||||
</Button>
|
||||
) : (
|
||||
<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()}
|
||||
{parentStack.length > 0 && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{" / "}
|
||||
{parentStack.map((p) => p.name).join(" / ")}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
</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">
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full rounded-xl" />
|
||||
<Skeleton key={i} className="h-14 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : !groups || groups.length === 0 ? (
|
||||
) : !cars || cars.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
{t("catalog.noCategories")}
|
||||
{t("catalog.noModels")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{groups.map((group) => (
|
||||
<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>
|
||||
<>
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft, ChevronRight, FolderOpen } from "lucide-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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { lazy, Suspense, useState, useEffect } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import type { Part, SchemaPic, Hotspot } from "@/hooks/use-parts";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user