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:
2026-06-09 20:52:51 +03:00
parent 5e1675f95a
commit fb2c28faf1
10 changed files with 1193 additions and 570 deletions

View File

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

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