feat: pcat v2 — full catalog browsing with cars, groups, schema viewer
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:
2026-03-12 20:03:48 +00:00
parent c61ad8c175
commit ca4437b245
8 changed files with 716 additions and 120 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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