feat(tecdoc): OEM detail page with TecDoc cross-references
Resolve a catalog OEM code to its TecDoc equivalents on a new
/dashboard/oem/$code page: the aftermarket parts that carry it
(brand + article number + image + EAN), buyable supplier
substitutes, and OE cross-references (same part under other makes).
- API: TecdocModule (read-only postgres-js client to the imported
`td` snapshot), GET /tecdoc/oem?code=. Normalisation-based match
(TecDoc stores `1J0 973 702`, catalog gives `1J0973702`); exact
match recovers ~1/10 vs normalised ~5/10 on real codes. Self-
disables without TECDOC_DB_* env → { matched: false }.
- Web: OEM code in the parts panel is now a link (new tab) to the
detail page; "N/A" stays plain text.
- Mirrors CatalogSourceDbModule (raw queries, no Drizzle modelling).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import { DemoModule } from "./demo/demo.module";
|
||||
import { EmailModule } from "./email/email.module";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { EmexModule } from "./integrations/emex/emex.module";
|
||||
import { TecdocModule } from "./integrations/tecdoc/tecdoc.module";
|
||||
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
|
||||
import { JobsModule } from "./jobs/jobs.module";
|
||||
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
|
||||
@@ -86,6 +87,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
||||
CategoriesModule,
|
||||
DemoModule,
|
||||
PartsModule,
|
||||
TecdocModule,
|
||||
JobsModule,
|
||||
EmexModule,
|
||||
TranslationsModule,
|
||||
|
||||
@@ -88,6 +88,14 @@ export default () => ({
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
tecdoc: {
|
||||
// Read-only lookup against the imported TecDoc snapshot (db `td`). When
|
||||
// enabled + url set, the OEM detail page resolves a part's OEM code to
|
||||
// TecDoc aftermarket equivalents + OE cross-references. Disabled → endpoint
|
||||
// returns { matched: false } and the UI shows an empty state.
|
||||
enabled: process.env.TECDOC_DB_ENABLED === "true",
|
||||
url: process.env.TECDOC_DB_URL,
|
||||
},
|
||||
otel: {
|
||||
enabled: process.env.OTEL_ENABLED === "true",
|
||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
|
||||
220
apps/api/src/integrations/tecdoc/tecdoc-source-db.service.ts
Normal file
220
apps/api/src/integrations/tecdoc/tecdoc-source-db.service.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import postgres, { type Sql } from "postgres";
|
||||
import type {
|
||||
TecdocArticle,
|
||||
TecdocCompatible,
|
||||
TecdocOeNumber,
|
||||
TecdocOemResult,
|
||||
} from "./tecdoc.types";
|
||||
|
||||
/**
|
||||
* Read-only lookup against the imported TecDoc snapshot (db `td` — a selective
|
||||
* copy of articles + OE numbers + aftermarket compatibility + images/eans; the
|
||||
* 29 GB vehicle-fitment table is intentionally excluded). Given an OEM code from
|
||||
* the sase catalog, returns the TecDoc articles that carry it as an OE number,
|
||||
* with their aftermarket equivalents and OE cross-references.
|
||||
*
|
||||
* Matching is normalisation-based, not exact: TecDoc stores OE codes with
|
||||
* spaces/dashes (`1J0 973 702`) while the catalog gives `1J0973702`, so both
|
||||
* sides are reduced to `[A-Z0-9]` uppercase before comparison (a precomputed
|
||||
* `code_norm` column, indexed, holds the TecDoc side). Exact matching recovers
|
||||
* almost nothing — verified ~1/10 vs normalised ~5/10 on real catalog codes.
|
||||
*
|
||||
* Never throws: disabled feature, too-short code, connection blip or no match
|
||||
* all collapse to `matched: false` so the UI has a single empty-state path.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TecdocSourceDbService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(TecdocSourceDbService.name);
|
||||
private sql: Sql | null = null;
|
||||
private enabled = false;
|
||||
|
||||
// Short normalised codes (e.g. "NA" from "N/A", single digits) collide across
|
||||
// unrelated parts — refuse to match below this length. Real OE numbers are 5+.
|
||||
private static readonly MIN_NORM_LEN = 5;
|
||||
private static readonly MAX_ARTICLES = 60;
|
||||
private static readonly MAX_AGG = 300;
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
const enabled = this.config.get<boolean>("tecdoc.enabled");
|
||||
const url = this.config.get<string>("tecdoc.url");
|
||||
if (!enabled || !url) {
|
||||
this.logger.log(`[tecdoc] disabled (enabled=${enabled}, urlSet=${Boolean(url)})`);
|
||||
return;
|
||||
}
|
||||
this.sql = postgres(url, {
|
||||
max: 5,
|
||||
idle_timeout: 30,
|
||||
connect_timeout: 10,
|
||||
prepare: false,
|
||||
});
|
||||
this.enabled = true;
|
||||
this.logger.log("[tecdoc] connected, OEM cross-reference lookup enabled");
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.sql) {
|
||||
await this.sql.end({ timeout: 5 });
|
||||
this.sql = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `1J0 973 702` / `1j0-973-702` → `1J0973702`. Used for both the input code
|
||||
* and JS-side dedupe; the TecDoc side is matched against the stored
|
||||
* `code_norm` (built with the identical rule at import time). */
|
||||
private static norm(code: string): string {
|
||||
return code.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||
}
|
||||
|
||||
async lookupByOem(rawCode: string): Promise<TecdocOemResult | null> {
|
||||
const query = (rawCode ?? "").trim();
|
||||
const queryNorm = TecdocSourceDbService.norm(query);
|
||||
const miss: TecdocOemResult = {
|
||||
query,
|
||||
queryNorm,
|
||||
matched: false,
|
||||
articles: [],
|
||||
aftermarketParts: [],
|
||||
oeCrossReferences: [],
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
if (!this.enabled || !this.sql) return miss;
|
||||
if (queryNorm.length < TecdocSourceDbService.MIN_NORM_LEN) return miss;
|
||||
|
||||
try {
|
||||
const rows = await this.sql<
|
||||
Array<{
|
||||
id: string;
|
||||
brand: string;
|
||||
article_number: string;
|
||||
name: string | null;
|
||||
spare_info: string | null;
|
||||
oe_numbers: TecdocOeNumber[];
|
||||
compatible: TecdocCompatible[];
|
||||
images: Array<{ url: string; thumb: string | null }>;
|
||||
eans: string[];
|
||||
}>
|
||||
>`
|
||||
WITH hit AS (
|
||||
SELECT DISTINCT article_id
|
||||
FROM article_oe_numbers
|
||||
WHERE code_norm = ${queryNorm}
|
||||
LIMIT ${TecdocSourceDbService.MAX_ARTICLES}
|
||||
)
|
||||
SELECT
|
||||
a.id::text AS id,
|
||||
b.name AS brand,
|
||||
a.article_number AS article_number,
|
||||
a.name AS name,
|
||||
a.spare_info AS spare_info,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('brand', o.brand, 'code', o.code))
|
||||
FROM (
|
||||
SELECT DISTINCT brand, code FROM article_oe_numbers
|
||||
WHERE article_id = a.id ORDER BY brand LIMIT 200
|
||||
) o
|
||||
), '[]') AS oe_numbers,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('brand', c.compatible_brand, 'article', c.compatible_article))
|
||||
FROM (
|
||||
SELECT DISTINCT compatible_brand, compatible_article FROM article_compatibility
|
||||
WHERE article_id = a.id ORDER BY compatible_brand LIMIT 200
|
||||
) c
|
||||
), '[]') AS compatible,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('url', i.image_url, 'thumb', i.thumb_url) ORDER BY i.sort_order)
|
||||
FROM (
|
||||
SELECT image_url, thumb_url, sort_order FROM article_images
|
||||
WHERE article_id = a.id ORDER BY sort_order LIMIT 8
|
||||
) i
|
||||
), '[]') AS images,
|
||||
COALESCE((
|
||||
SELECT json_agg(e.ean) FROM (
|
||||
SELECT DISTINCT ean FROM article_ean_numbers WHERE article_id = a.id LIMIT 20
|
||||
) e
|
||||
), '[]') AS eans
|
||||
FROM hit
|
||||
JOIN articles a ON a.id = hit.article_id
|
||||
JOIN article_brands b ON b.id = a.brand_id
|
||||
ORDER BY b.name, a.article_number
|
||||
`;
|
||||
|
||||
if (rows.length === 0) return miss;
|
||||
|
||||
let truncated = rows.length >= TecdocSourceDbService.MAX_ARTICLES;
|
||||
|
||||
const articles: TecdocArticle[] = rows.map((r) => {
|
||||
if (r.oe_numbers.length >= 200 || r.compatible.length >= 200) truncated = true;
|
||||
return {
|
||||
id: r.id,
|
||||
brand: r.brand,
|
||||
articleNumber: r.article_number,
|
||||
name: r.name,
|
||||
spareInfo: r.spare_info,
|
||||
images: r.images,
|
||||
eans: r.eans,
|
||||
oeNumbers: r.oe_numbers,
|
||||
compatible: r.compatible,
|
||||
};
|
||||
});
|
||||
|
||||
// ── Aggregate: buyable aftermarket part numbers ──────────────────────
|
||||
// The matched articles are themselves aftermarket parts; their
|
||||
// compatibility rows add equivalent numbers from other supplier brands.
|
||||
const afterSeen = new Set<string>();
|
||||
const aftermarketParts: TecdocOemResult["aftermarketParts"] = [];
|
||||
const pushAfter = (brand: string, articleNumber: string, thumb: string | null) => {
|
||||
const key = `${brand.toUpperCase().trim()}␟${TecdocSourceDbService.norm(articleNumber)}`;
|
||||
if (afterSeen.has(key) || !articleNumber.trim()) return;
|
||||
afterSeen.add(key);
|
||||
if (aftermarketParts.length < TecdocSourceDbService.MAX_AGG) {
|
||||
aftermarketParts.push({ brand, articleNumber, thumb });
|
||||
} else {
|
||||
truncated = true;
|
||||
}
|
||||
};
|
||||
for (const a of articles) {
|
||||
pushAfter(a.brand, a.articleNumber, a.images[0]?.thumb ?? a.images[0]?.url ?? null);
|
||||
}
|
||||
for (const a of articles) {
|
||||
for (const c of a.compatible) pushAfter(c.brand, c.article, null);
|
||||
}
|
||||
|
||||
// ── Aggregate: OE cross-references (same part, other makes) ───────────
|
||||
// Exclude restatements of the queried code itself (same normalised code).
|
||||
const oeSeen = new Set<string>();
|
||||
const oeCrossReferences: TecdocOeNumber[] = [];
|
||||
for (const a of articles) {
|
||||
for (const oe of a.oeNumbers) {
|
||||
const codeNorm = TecdocSourceDbService.norm(oe.code);
|
||||
if (codeNorm === queryNorm) continue;
|
||||
const key = `${oe.brand.toUpperCase().trim()}␟${codeNorm}`;
|
||||
if (oeSeen.has(key)) continue;
|
||||
oeSeen.add(key);
|
||||
if (oeCrossReferences.length < TecdocSourceDbService.MAX_AGG) {
|
||||
oeCrossReferences.push(oe);
|
||||
} else {
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
queryNorm,
|
||||
matched: true,
|
||||
articles,
|
||||
aftermarketParts,
|
||||
oeCrossReferences,
|
||||
truncated,
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.warn(`[tecdoc] lookup failed (oem=${query}): ${(err as Error).message}`);
|
||||
return miss;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
apps/api/src/integrations/tecdoc/tecdoc.controller.ts
Normal file
17
apps/api/src/integrations/tecdoc/tecdoc.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get, Query } from "@nestjs/common";
|
||||
import { TecdocSourceDbService } from "./tecdoc-source-db.service";
|
||||
|
||||
@Controller("tecdoc")
|
||||
export class TecdocController {
|
||||
constructor(private readonly tecdoc: TecdocSourceDbService) {}
|
||||
|
||||
/**
|
||||
* Resolve an OEM code from the catalog to its TecDoc equivalents.
|
||||
* `GET /tecdoc/oem?code=1J0973702` → { matched, articles, aftermarketParts,
|
||||
* oeCrossReferences }. Always 200 with `matched: false` on any miss.
|
||||
*/
|
||||
@Get("oem")
|
||||
async oem(@Query("code") code: string) {
|
||||
return this.tecdoc.lookupByOem(code ?? "");
|
||||
}
|
||||
}
|
||||
15
apps/api/src/integrations/tecdoc/tecdoc.module.ts
Normal file
15
apps/api/src/integrations/tecdoc/tecdoc.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TecdocSourceDbService } from "./tecdoc-source-db.service";
|
||||
import { TecdocController } from "./tecdoc.controller";
|
||||
|
||||
/**
|
||||
* OEM cross-reference lookup against the imported TecDoc snapshot (db `td`).
|
||||
* Raw read-only queries — intentionally no Drizzle schema modelling, mirroring
|
||||
* CatalogSourceDbModule. Self-disables when TECDOC_DB_* env is unset.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [TecdocController],
|
||||
providers: [TecdocSourceDbService],
|
||||
exports: [TecdocSourceDbService],
|
||||
})
|
||||
export class TecdocModule {}
|
||||
50
apps/api/src/integrations/tecdoc/tecdoc.types.ts
Normal file
50
apps/api/src/integrations/tecdoc/tecdoc.types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/** An OE (original-equipment) number cross-reference: the same physical part as
|
||||
* catalogued by a vehicle manufacturer (e.g. VAG `1J0 973 702`). */
|
||||
export interface TecdocOeNumber {
|
||||
brand: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** An aftermarket equivalent: a buyable part number from a supplier brand
|
||||
* (e.g. FEBI BILSTEIN `171903`). */
|
||||
export interface TecdocCompatible {
|
||||
brand: string;
|
||||
article: string;
|
||||
}
|
||||
|
||||
export interface TecdocImage {
|
||||
url: string;
|
||||
thumb: string | null;
|
||||
}
|
||||
|
||||
/** One TecDoc article whose OE number list contains the queried OEM code. */
|
||||
export interface TecdocArticle {
|
||||
id: string;
|
||||
brand: string;
|
||||
articleNumber: string;
|
||||
name: string | null;
|
||||
spareInfo: string | null;
|
||||
images: TecdocImage[];
|
||||
eans: string[];
|
||||
oeNumbers: TecdocOeNumber[];
|
||||
compatible: TecdocCompatible[];
|
||||
}
|
||||
|
||||
/** Response of the OEM detail lookup. `matched: false` covers every miss —
|
||||
* feature disabled, code too short, or no TecDoc article carries that OE
|
||||
* number — so the UI has a single empty-state path. */
|
||||
export interface TecdocOemResult {
|
||||
query: string;
|
||||
queryNorm: string;
|
||||
matched: boolean;
|
||||
/** Distinct articles whose OE list contains the queried code. */
|
||||
articles: TecdocArticle[];
|
||||
/** Deduped buyable aftermarket part numbers across all matched articles
|
||||
* (the matched articles themselves + their compatibility entries). */
|
||||
aftermarketParts: Array<{ brand: string; articleNumber: string; thumb: string | null }>;
|
||||
/** Deduped OE cross-references across all matched articles, excluding the
|
||||
* queried code itself — i.e. the same part's numbers under other makes. */
|
||||
oeCrossReferences: TecdocOeNumber[];
|
||||
/** True when any per-article list or the article set hit its cap. */
|
||||
truncated: boolean;
|
||||
}
|
||||
@@ -352,7 +352,7 @@ export function PartsPanel({
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{part.oemCode && (
|
||||
{part.oemCode && part.oemCode !== "N/A" && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
@@ -365,7 +365,32 @@ export function PartsPanel({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{part.oemCode}
|
||||
{part.oemCode && part.oemCode !== "N/A" ? (
|
||||
// Click → OEM detail page (TecDoc cross-reference) in a
|
||||
// new tab so the catalog/schema context stays put. Plain
|
||||
// anchor (not router Link) — a fresh load resolves the
|
||||
// route and keeps this cell router-context-free.
|
||||
<a
|
||||
href={`/dashboard/oem/${encodeURIComponent(part.oemCode)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Uyumlu parça kodlarını gör"
|
||||
className="underline decoration-dotted underline-offset-2 transition-colors hover:text-foreground hover:decoration-solid"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
capture("oem_detail_opened", {
|
||||
oem_code: part.oemCode,
|
||||
part_id: part.id,
|
||||
vehicle_id: vehicleId,
|
||||
category_id: categoryId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{part.oemCode}
|
||||
</a>
|
||||
) : (
|
||||
part.oemCode
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{part.quantity}</td>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
261
apps/web/src/routes/dashboard/oem.$code.tsx
Normal file
261
apps/web/src/routes/dashboard/oem.$code.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { Badge, Button, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { AlertCircle, ArrowLeft, Check, Copy, ImageOff, PackageSearch } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
// ─── Types (mirror apps/api TecdocOemResult) ────────────────────────────────
|
||||
|
||||
interface OeNumber {
|
||||
brand: string;
|
||||
code: string;
|
||||
}
|
||||
interface TecdocArticle {
|
||||
id: string;
|
||||
brand: string;
|
||||
articleNumber: string;
|
||||
name: string | null;
|
||||
spareInfo: string | null;
|
||||
images: Array<{ url: string; thumb: string | null }>;
|
||||
eans: string[];
|
||||
oeNumbers: OeNumber[];
|
||||
compatible: Array<{ brand: string; article: string }>;
|
||||
}
|
||||
interface TecdocOemResult {
|
||||
query: string;
|
||||
queryNorm: string;
|
||||
matched: boolean;
|
||||
articles: TecdocArticle[];
|
||||
aftermarketParts: Array<{ brand: string; articleNumber: string; thumb: string | null }>;
|
||||
oeCrossReferences: OeNumber[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
// ─── Reusable copy-to-clipboard chip ────────────────────────────────────────
|
||||
|
||||
function CopyCode({ code, className }: { code: string; className?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timer = useRef<number | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current) window.clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
if (timer.current) window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setCopied(false), 1500);
|
||||
capture("oem_xref_code_copied", { code });
|
||||
}}
|
||||
title="Kopyala"
|
||||
className={`group inline-flex items-center gap-1.5 font-mono text-xs ${className ?? ""}`}
|
||||
>
|
||||
<span>{code}</span>
|
||||
{copied ? (
|
||||
<Check className="size-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Image with graceful fallback ───────────────────────────────────────────
|
||||
|
||||
function PartThumb({ src, alt }: { src: string | null; alt: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (!src || failed) {
|
||||
return (
|
||||
<div className="flex size-16 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<ImageOff className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
className="size-16 shrink-0 rounded-lg border border-border bg-white object-contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Route ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const Route = createFileRoute("/dashboard/oem/$code")({
|
||||
component: OemDetailPage,
|
||||
});
|
||||
|
||||
function OemDetailPage() {
|
||||
const { code } = Route.useParams();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["tecdoc-oem", code],
|
||||
queryFn: () => api.get<TecdocOemResult>(`/tecdoc/oem?code=${encodeURIComponent(code)}`),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
capture("oem_detail_viewed", {
|
||||
oem_code: code,
|
||||
matched: data.matched,
|
||||
article_count: data.articles.length,
|
||||
aftermarket_count: data.aftermarketParts.length,
|
||||
oe_xref_count: data.oeCrossReferences.length,
|
||||
});
|
||||
}
|
||||
}, [data, code]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* ─── Header ─────────────────────────────────────────────────────── */}
|
||||
<header className="flex items-start gap-4">
|
||||
<Button asChild variant="ghost" size="icon" className="mt-0.5 shrink-0">
|
||||
<Link to="/dashboard/search" aria-label="Geri">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
OEM kodu
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||
<h1 className="font-mono text-2xl font-bold tracking-tight break-all">{code}</h1>
|
||||
<CopyCode
|
||||
code={code}
|
||||
className="rounded-md border border-border px-2 py-1 hover:bg-accent"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
TecDoc kataloğundan uyumlu parça kodları ve muadiller
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ─── Loading ────────────────────────────────────────────────────── */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-28 w-full rounded-xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Error ──────────────────────────────────────────────────────── */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-destructive/40 bg-destructive/10 p-4">
|
||||
<AlertCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
Uyumlu parça bilgisi yüklenemedi. Lütfen tekrar deneyin.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Empty state (no TecDoc match) ──────────────────────────────── */}
|
||||
{data && !data.matched && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-border bg-background px-6 py-12 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-xl bg-muted">
|
||||
<PackageSearch className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="font-medium">Bu OEM kodu için TecDoc karşılığı bulunamadı</p>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
Bağlantı parçaları, klipsler ve bazı orijinal kodlar TecDoc kapsamında olmayabilir.
|
||||
Katalog büyüdükçe eşleşme oranı artar.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Results ────────────────────────────────────────────────────── */}
|
||||
{data?.matched && (
|
||||
<div className="space-y-6">
|
||||
{/* Summary */}
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
<Badge variant="secondary">{data.articles.length} eşleşen parça</Badge>
|
||||
<Badge variant="secondary">{data.aftermarketParts.length} yan sanayi numarası</Badge>
|
||||
<Badge variant="secondary">{data.oeCrossReferences.length} muadil OE kodu</Badge>
|
||||
{data.truncated && (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
liste kısaltıldı
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Matched TecDoc articles (highest-confidence: carry this OEM directly) */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">Bu OEM'i taşıyan parçalar</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{data.articles.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex gap-3 rounded-xl border border-border bg-background p-3"
|
||||
>
|
||||
<PartThumb src={a.images[0]?.thumb ?? a.images[0]?.url ?? null} alt={a.brand} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{a.brand}</p>
|
||||
<CopyCode code={a.articleNumber} className="mt-0.5" />
|
||||
{a.name && a.name !== a.articleNumber && (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{a.name}</p>
|
||||
)}
|
||||
{a.eans.length > 0 && (
|
||||
<p className="mt-1 font-mono text-[11px] text-muted-foreground">
|
||||
EAN: {a.eans[0]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Aftermarket equivalents (buyable substitutes across supplier brands) */}
|
||||
{data.aftermarketParts.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">Yan sanayi muadilleri</h2>
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-border">
|
||||
{data.aftermarketParts.map((p) => (
|
||||
<tr key={`${p.brand}-${p.articleNumber}`} className="hover:bg-accent/50">
|
||||
<td className="px-4 py-2 font-medium">{p.brand}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<CopyCode code={p.articleNumber} className="hover:text-foreground" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* OE cross-references (same physical part, other vehicle makes) */}
|
||||
{data.oeCrossReferences.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">Muadil orijinal (OE) kodları</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.oeCrossReferences.map((oe) => (
|
||||
<span
|
||||
key={`${oe.brand}-${oe.code}`}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border bg-background px-2.5 py-1.5"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">{oe.brand}</span>
|
||||
<CopyCode code={oe.code} className="hover:text-foreground" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -100,6 +100,10 @@ services:
|
||||
# memory: catalog-wide bridge had 7-114x noise; only add a catalog once
|
||||
# its per-vehicle bridge is wired & OEM-verified.
|
||||
- EMEX_SOURCE_DB_ALLOWED_CATALOGS=${EMEX_SOURCE_DB_ALLOWED_CATALOGS:-}
|
||||
# TecDoc snapshot lookup (db `td`) backing the OEM detail page. Off until
|
||||
# both vars are set in Coolify; see sase-prod-db-api-access memory.
|
||||
- TECDOC_DB_ENABLED=${TECDOC_DB_ENABLED:-false}
|
||||
- TECDOC_DB_URL=${TECDOC_DB_URL:-}
|
||||
depends_on:
|
||||
sase-redis:
|
||||
condition: service_healthy
|
||||
|
||||
Reference in New Issue
Block a user