feat: EMEX catalog integration — schema, data migration, backend & frontend
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user