feat: EMEX catalog integration — schema, data migration, backend & frontend
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled

Redesign emex_* tables from vehicle-centric to catalog-centric structure with
junction tables (emex_vehicle_group_links, emex_vehicle_part_links). Migrate
92M+ rows from emex DB via postgres_fdw. Add EmexCatalogService for browse
and VIN pre-scraped matching, with Redis caching. Frontend catalog page now
has PL24/Emex tabs with full drill-down routes (brand → vehicle → group → parts).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 04:06:29 +00:00
parent 63f6309bf3
commit 1e8a049219
17 changed files with 1351 additions and 75 deletions

View File

@@ -1,9 +1,10 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Skeleton } from "@sase/ui";
import { Skeleton, Tabs, TabsContent, TabsList, TabsTrigger } from "@sase/ui";
import { Library, Lock } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/dashboard/catalog/")({
component: CatalogBrandsPage,
@@ -17,14 +18,29 @@ interface CatalogBrand {
hasAccess: boolean;
}
interface EmexBrand {
id: string;
catalogId: string;
code: string | null;
brandName: string;
description: string | null;
}
function CatalogBrandsPage() {
const { t } = useTranslation();
const [tab, setTab] = useState("pl24");
const { data: brands, isLoading } = useQuery({
queryKey: ["catalog-brands"],
queryFn: () => api.get<CatalogBrand[]>("/catalog/brands"),
});
const { data: emexBrands, isLoading: emexLoading } = useQuery({
queryKey: ["emex-brands"],
queryFn: () => api.get<EmexBrand[]>("/catalog/emex/brands"),
enabled: tab === "emex",
});
return (
<div className="space-y-6">
<div>
@@ -32,29 +48,64 @@ function CatalogBrandsPage() {
<p className="text-sm text-muted-foreground">{t("catalog.brands")}</p>
</div>
{isLoading ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={`brand-skel-${i}`} className="h-28 w-full rounded-xl" />
))}
</div>
) : !brands || brands.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Library className="mb-4 size-12 text-muted-foreground/40" />
<p className="text-muted-foreground">{t("catalog.noBrands")}</p>
</div>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{brands.map((brand) => (
<BrandCard key={brand.brandName} brand={brand} />
))}
</div>
)}
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="pl24">{t("catalog.tabPl24")}</TabsTrigger>
<TabsTrigger value="emex">{t("catalog.tabEmex")}</TabsTrigger>
</TabsList>
<TabsContent value="pl24" className="mt-4">
{isLoading ? (
<BrandGridSkeleton />
) : !brands || brands.length === 0 ? (
<EmptyBrands message={t("catalog.noBrands")} />
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{brands.map((brand) => (
<PL24BrandCard key={brand.brandName} brand={brand} />
))}
</div>
)}
</TabsContent>
<TabsContent value="emex" className="mt-4">
{emexLoading ? (
<BrandGridSkeleton />
) : !emexBrands || emexBrands.length === 0 ? (
<EmptyBrands message={t("catalog.noBrands")} />
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{emexBrands.map((brand) => (
<EmexBrandCard key={brand.catalogId} brand={brand} />
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}
function BrandCard({ brand }: { brand: CatalogBrand }) {
function BrandGridSkeleton() {
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={`brand-skel-${i}`} className="h-28 w-full rounded-xl" />
))}
</div>
);
}
function EmptyBrands({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Library className="mb-4 size-12 text-muted-foreground/40" />
<p className="text-muted-foreground">{message}</p>
</div>
);
}
function PL24BrandCard({ brand }: { brand: CatalogBrand }) {
const { t } = useTranslation();
if (!brand.hasAccess) {
@@ -95,3 +146,21 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
</Link>
);
}
function EmexBrandCard({ brand }: { brand: EmexBrand }) {
return (
<Link
to="/dashboard/catalog/emex/$catalogCode"
params={{ catalogCode: brand.catalogId }}
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-colors hover:bg-accent hover:border-accent-foreground/20"
>
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-orange-500/10">
<Library className="size-5 text-orange-500" />
</div>
<p className="text-sm font-semibold">{brand.brandName}</p>
{brand.description && brand.description !== brand.brandName && (
<p className="mt-1 text-xs text-muted-foreground">{brand.description}</p>
)}
</Link>
);
}

View File

@@ -0,0 +1,82 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { ArrowLeft, Car } from "lucide-react";
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode/")({
component: EmexVehicleListPage,
});
interface EmexVehicle {
id: string;
vehicleId: string;
name: string | null;
engine: string | null;
engineCode: string | null;
bodyType: string | null;
transmission: string | null;
yearFrom: number | null;
yearTo: number | null;
optionsRaw: string | null;
}
function EmexVehicleListPage() {
const { t } = useTranslation();
const { catalogCode } = Route.useParams();
const { data: vehicles, isLoading } = useQuery({
queryKey: ["emex-vehicles", catalogCode],
queryFn: () => api.get<EmexVehicle[]>(`/catalog/emex/brands/${catalogCode}/vehicles`),
});
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Link to="/dashboard/catalog" search={{}}>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-1 size-4" />
{t("catalog.backToBrands")}
</Button>
</Link>
<h1 className="text-xl font-bold">{decodeURIComponent(catalogCode)}</h1>
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`v-skel-${i}`} className="h-16 w-full rounded-lg" />
))}
</div>
) : !vehicles || vehicles.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">{t("catalog.noModels")}</p>
) : (
<div className="space-y-2">
{vehicles.map((v) => (
<Link
key={v.id}
to="/dashboard/catalog/emex/$catalogCode/$vehicleId"
params={{ catalogCode, vehicleId: v.id }}
className="flex items-center gap-3 rounded-lg border border-border bg-card p-3 transition-colors hover:bg-accent"
>
<Car className="size-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{v.name || v.vehicleId}</p>
<p className="text-xs text-muted-foreground">
{[
v.engine,
v.yearFrom && v.yearTo ? `${v.yearFrom}-${v.yearTo}` : v.yearFrom,
v.optionsRaw,
]
.filter(Boolean)
.join(" | ")}
</p>
</div>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { ArrowLeft, FolderOpen, ChevronRight } from "lucide-react";
export const Route = createFileRoute(
"/dashboard/catalog_/emex/$catalogCode_/$vehicleId/",
)({
component: EmexGroupListPage,
});
interface EmexGroup {
id: string;
groupId: string;
name: string;
nameOriginal: string | null;
hasParts: boolean | null;
hasChildren: boolean | null;
}
function EmexGroupListPage() {
const { t } = useTranslation();
const { catalogCode, vehicleId } = Route.useParams();
const { data: groups, isLoading } = useQuery({
queryKey: ["emex-groups", vehicleId],
queryFn: () => api.get<EmexGroup[]>(`/catalog/emex/vehicles/${vehicleId}/groups`),
});
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Link
to="/dashboard/catalog/emex/$catalogCode"
params={{ catalogCode }}
>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-1 size-4" />
{t("catalog.backToModels")}
</Button>
</Link>
<h1 className="text-xl font-bold">{t("catalog.categories")}</h1>
</div>
{isLoading ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 12 }).map((_, i) => (
<Skeleton key={`g-skel-${i}`} className="h-14 w-full rounded-lg" />
))}
</div>
) : !groups || groups.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">
{t("catalog.noCategories")}
</p>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{groups.map((g) => (
<Link
key={g.id}
to="/dashboard/catalog/emex/$catalogCode/$vehicleId/groups/$groupId"
params={{ catalogCode, vehicleId, groupId: g.id }}
className="flex items-center gap-3 rounded-lg border border-border bg-card p-3 transition-colors hover:bg-accent"
>
<FolderOpen className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-sm">{g.name}</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,185 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { ArrowLeft, Copy, Check, ImageOff } from "lucide-react";
import { useState, useCallback } from "react";
export const Route = createFileRoute(
"/dashboard/catalog_/emex/$catalogCode_/$vehicleId_/groups/$groupId",
)({
component: EmexGroupPartsPage,
});
interface EmexPart {
id: string;
partNumber: string | null;
name: string;
nameOriginal: string | null;
description: string | null;
oemNumber: string | null;
hotspotIndex: number | null;
quantity: number | null;
position: string | null;
}
interface EmexSchemaPic {
id: string;
imageUrl: string | null;
localPath: string | null;
hotspots: unknown;
width: number | null;
height: number | null;
sortOrder: number | null;
}
interface EmexGroupParts {
group: {
id: string;
groupId: string;
name: string;
nameOriginal: string | null;
};
parts: EmexPart[];
schemaPics: EmexSchemaPic[];
}
function EmexGroupPartsPage() {
const { t } = useTranslation();
const { catalogCode, vehicleId, groupId } = Route.useParams();
const { data, isLoading } = useQuery({
queryKey: ["emex-group-parts", vehicleId, groupId],
queryFn: () =>
api.get<EmexGroupParts>(`/catalog/emex/vehicles/${vehicleId}/groups/${groupId}`),
});
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Link
to="/dashboard/catalog/emex/$catalogCode/$vehicleId"
params={{ catalogCode, vehicleId }}
>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-1 size-4" />
{t("catalog.backToCategories")}
</Button>
</Link>
<h1 className="text-xl font-bold">{data?.group?.name || t("catalog.parts")}</h1>
</div>
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-64 w-full rounded-lg" />
<Skeleton className="h-96 w-full rounded-lg" />
</div>
) : !data ? (
<p className="py-8 text-center text-muted-foreground">{t("catalog.noParts")}</p>
) : (
<div className="space-y-6">
{/* Schema images */}
{data.schemaPics.length > 0 && (
<div className="space-y-2">
{data.schemaPics.map((pic) => (
<div
key={pic.id}
className="overflow-hidden rounded-lg border border-border bg-card"
>
{pic.imageUrl ? (
<img
src={pic.imageUrl}
alt={data.group?.name || "Schema"}
className="w-full object-contain"
loading="lazy"
/>
) : (
<div className="flex h-48 items-center justify-center bg-muted">
<ImageOff className="size-8 text-muted-foreground" />
</div>
)}
</div>
))}
</div>
)}
{/* Parts table */}
{data.parts.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left font-medium">#</th>
<th className="px-3 py-2 text-left font-medium">
{t("catalog.partNumber")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("catalog.partName")}
</th>
<th className="px-3 py-2 text-left font-medium">OEM</th>
<th className="px-3 py-2 text-center font-medium">
{t("catalog.qty")}
</th>
<th className="px-3 py-2 text-center font-medium" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{data.parts.map((part, idx) => (
<PartRow key={part.id} part={part} idx={idx} />
))}
</tbody>
</table>
</div>
) : (
<p className="py-4 text-center text-muted-foreground">{t("catalog.noParts")}</p>
)}
</div>
)}
</div>
);
}
function PartRow({ part, idx }: { part: EmexPart; idx: number }) {
const [copied, setCopied] = useState<string | null>(null);
const copyOem = useCallback((code: string) => {
navigator.clipboard.writeText(code);
setCopied(code);
setTimeout(() => setCopied(null), 1500);
}, []);
const oemCode = part.partNumber || part.oemNumber;
return (
<tr className="hover:bg-muted/30">
<td className="px-3 py-2 text-muted-foreground">
{part.hotspotIndex ?? idx + 1}
</td>
<td className="px-3 py-2 font-mono text-xs">
{oemCode && (
<button
type="button"
onClick={() => copyOem(oemCode)}
className="inline-flex items-center gap-1 rounded px-1 py-0.5 hover:bg-accent"
>
{oemCode}
{copied === oemCode ? (
<Check className="size-3 text-green-500" />
) : (
<Copy className="size-3 text-muted-foreground" />
)}
</button>
)}
</td>
<td className="px-3 py-2">{part.name}</td>
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">
{part.oemNumber && part.oemNumber !== part.partNumber ? part.oemNumber : ""}
</td>
<td className="px-3 py-2 text-center">{part.quantity ?? 1}</td>
<td className="px-3 py-2 text-center text-xs text-muted-foreground">
{part.position}
</td>
</tr>
);
}