feat: add Pcat catalog browsing — import pc dump, API endpoints, frontend routes
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled

Import parts-catalogs.com dump into pc schema (72 catalogs, 2788 models,
134K groups). Add pcat-catalog service/controller with Redis caching.
Create frontend routes for catalog → model → group navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 10:52:41 +00:00
parent 13bd112add
commit c61ad8c175
7 changed files with 976 additions and 494 deletions

View File

@@ -3,14 +3,16 @@ 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";
import { PL24Module } from "../integrations/pl24/pl24.module";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
import { StorageModule } from "../storage/storage.module";
@Module({
imports: [PL24Module, SubscriptionsModule, StorageModule],
controllers: [CatalogController, EmexCatalogController],
providers: [CatalogService, EmexCatalogService],
exports: [CatalogService, EmexCatalogService],
controllers: [CatalogController, EmexCatalogController, PcatCatalogController],
providers: [CatalogService, EmexCatalogService, PcatCatalogService],
exports: [CatalogService, EmexCatalogService, PcatCatalogService],
})
export class CatalogModule {}

View File

@@ -0,0 +1,25 @@
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.getGroups(catalogId, parentId);
}
}

View File

@@ -0,0 +1,132 @@
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;
modelsCount: number;
}
export interface PcatModelDto {
id: string;
catalogId: string;
name: string;
imgUrl: string | null;
}
export interface PcatGroupDto {
id: string;
catalogId: string;
parentId: string | null;
name: string;
imgUrl: string | null;
hasSubgroups: boolean;
hasParts: boolean;
}
const CACHE_TTL = {
catalogs: 86400, // 24h
models: 43200, // 12h
groups: 21600, // 6h
};
@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 = "pcat:catalogs";
const cached = await this.redis.getJson<PcatCatalogDto[]>(cacheKey);
if (cached) return cached;
const rows = await this.db.execute<{
id: string;
name: string;
models_count: number;
}>(sql`SELECT id, name, models_count FROM pc.catalogs WHERE is_active = true ORDER BY name`);
const result: PcatCatalogDto[] = rows.map((r) => ({
id: r.id,
name: r.name,
modelsCount: r.models_count ?? 0,
}));
await this.redis.setJson(cacheKey, result, CACHE_TTL.catalogs);
return result;
}
async getModels(catalogId: string): Promise<PcatModelDto[]> {
const cacheKey = `pcat: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;
}>(
sql`SELECT id, catalog_id, name, img_url 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,
}));
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"}`;
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`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`,
)
: 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`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`,
);
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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,12 @@ interface EmexBrand {
description: string | null;
}
interface PcatCatalog {
id: string;
name: string;
modelsCount: number;
}
function CatalogBrandsPage() {
const { t } = useTranslation();
const [tab, setTab] = useState("pl24");
@@ -35,6 +41,12 @@ function CatalogBrandsPage() {
queryFn: () => api.get<CatalogBrand[]>("/catalog/brands"),
});
const { data: pcatCatalogs, isLoading: pcatLoading } = useQuery({
queryKey: ["pcat-catalogs"],
queryFn: () => api.get<PcatCatalog[]>("/catalog/pcat/catalogs"),
enabled: tab === "pcat",
});
const { data: emexBrands, isLoading: emexLoading } = useQuery({
queryKey: ["emex-brands"],
queryFn: () => api.get<EmexBrand[]>("/catalog/emex/brands"),
@@ -76,7 +88,17 @@ function CatalogBrandsPage() {
</TabsContent>
<TabsContent value="pcat" className="mt-4">
<ComingSoonPlaceholder />
{pcatLoading ? (
<BrandGridSkeleton />
) : !pcatCatalogs || pcatCatalogs.length === 0 ? (
<EmptyBrands message={t("catalog.noBrands")} />
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{pcatCatalogs.map((cat) => (
<PcatCatalogCard key={cat.id} catalog={cat} />
))}
</div>
)}
</TabsContent>
<TabsContent value="emex" className="mt-4">
@@ -171,6 +193,24 @@ function PL24BrandCard({ brand }: { brand: CatalogBrand }) {
);
}
function PcatCatalogCard({ catalog }: { catalog: PcatCatalog }) {
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>
<p className="text-sm font-semibold">{catalog.name}</p>
<p className="mt-1 text-xs text-muted-foreground">
{catalog.modelsCount} model
</p>
</Link>
);
}
function EmexBrandCard({ brand }: { brand: EmexBrand }) {
return (
<Link

View File

@@ -0,0 +1,84 @@
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, 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;
}
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>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,132 @@
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/",
)({
component: PcatGroupsPage,
});
interface PcatGroup {
id: string;
catalogId: string;
parentId: string | null;
name: string;
imgUrl: string | null;
hasSubgroups: boolean;
hasParts: boolean;
}
function PcatGroupsPage() {
const { t } = useTranslation();
const { catalogId, modelId } = Route.useParams();
// 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 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"
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>
</div>
{isLoading ? (
<div className="grid grid-cols-2 gap-4 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) => (
<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>
);
}