feat(pl24): make "bk. tablo:" BOM cross-references navigable
PL24 BOM emits "see table NNN-NNN" reference rows (oem N/A, target code in
remark) with NO upstream link. Resolve the code against the vehicle's
illustration index (codes live in category names as {NNN-NNN}) and render
jump links. Unresolved targets (branch not seeded yet) deep-link a pre-filled
catalog search via ?q=.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1462,7 +1462,7 @@ export class CategoriesService {
|
||||
name: category.name,
|
||||
description: category.nameOriginal || null,
|
||||
parentId: category.parentId || null,
|
||||
parts: dbParts,
|
||||
parts: await this.resolvePartReferences(dbParts, category.vehicleId),
|
||||
schemaPics: mappedPics,
|
||||
hotspots: mappedHotspots,
|
||||
// Only meaningful when the lists are empty: true means a source fetch
|
||||
@@ -1471,6 +1471,51 @@ export class CategoriesService {
|
||||
};
|
||||
}
|
||||
|
||||
// PL24 BOM "bk. tablo:NNN-NNN" rows are cross-references to another
|
||||
// illustration, not real parts (oem_code "N/A", target code in `remark`).
|
||||
// Upstream gives us NO navigable link for them — only the text code — so we
|
||||
// resolve it ourselves: every illustration's code lives in its category name
|
||||
// as `{NNN-NNN}`, so we build a code→categoryId index for this vehicle and
|
||||
// attach it. Codes whose target branch hasn't been lazily seeded yet resolve
|
||||
// to null; the UI then falls back to a pre-filled catalog search.
|
||||
private static readonly REF_CODE_RE = /\d{3}-\d{3}/g;
|
||||
private static readonly REF_CODE_IN_NAME_RE = /\{(\d{3}-\d{3})\}/g;
|
||||
|
||||
private async resolvePartReferences<T extends { name: string; remark: string | null }>(
|
||||
rows: T[],
|
||||
vehicleId: string | null,
|
||||
): Promise<(T & { references?: Array<{ code: string; categoryId: string | null }> })[]> {
|
||||
const isRef = (p: T) => p.name?.includes("bk. tablo");
|
||||
if (!vehicleId || !rows.some(isRef)) return rows;
|
||||
|
||||
const cats = await this.db
|
||||
.select({ id: categories.id, name: categories.name })
|
||||
.from(categories)
|
||||
.where(eq(categories.vehicleId, vehicleId));
|
||||
|
||||
const index = new Map<string, string>();
|
||||
for (const c of cats) {
|
||||
for (const m of c.name.matchAll(CategoriesService.REF_CODE_IN_NAME_RE)) {
|
||||
if (!index.has(m[1])) index.set(m[1], c.id);
|
||||
}
|
||||
}
|
||||
|
||||
return rows.map((p) => {
|
||||
if (!isRef(p)) return p;
|
||||
// remark stores the code possibly escaped ("819\-031") and sometimes
|
||||
// multiple codes concatenated ("141-045141-065141-075") plus position
|
||||
// hints ("803-070 POS.28+29") — extract every NNN-NNN occurrence.
|
||||
const codes = [
|
||||
...new Set((p.remark?.replace(/\\/g, "") ?? "").match(CategoriesService.REF_CODE_RE) ?? []),
|
||||
];
|
||||
if (codes.length === 0) return p;
|
||||
return {
|
||||
...p,
|
||||
references: codes.map((code) => ({ code, categoryId: index.get(code) ?? null })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getById(categoryId: string) {
|
||||
const [category] = await this.db
|
||||
.select()
|
||||
|
||||
@@ -68,13 +68,16 @@ function Breadcrumb({ trail, tail }: { trail: Crumb[]; tail?: string }) {
|
||||
export function CatalogSearch({
|
||||
vehicleId,
|
||||
onActiveChange,
|
||||
initialQuery,
|
||||
}: {
|
||||
vehicleId: string;
|
||||
onActiveChange?: (active: boolean) => void;
|
||||
/** Pre-fill the search box (e.g. deep-linked from a "bk. tablo:" reference). */
|
||||
initialQuery?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [input, setInput] = useState("");
|
||||
const [input, setInput] = useState(initialQuery ?? "");
|
||||
const debounced = useDebounced(input.trim(), 300);
|
||||
const active = debounced.length >= 2;
|
||||
|
||||
@@ -83,6 +86,11 @@ export function CatalogSearch({
|
||||
onActiveChange?.(active);
|
||||
}, [active]);
|
||||
|
||||
// Adopt a freshly deep-linked query even if this component stays mounted.
|
||||
useEffect(() => {
|
||||
if (initialQuery) setInput(initialQuery);
|
||||
}, [initialQuery]);
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: ["catalog-search", vehicleId, debounced],
|
||||
queryFn: () =>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { api } from "@/lib/api-client";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import { Button, Skeleton, cn } from "@sase/ui";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowRight, Check, Copy } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface PartsPanelProps {
|
||||
@@ -18,10 +19,39 @@ const SKELETON_ROW_KEYS = ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7"] as co
|
||||
export function PartsPanel({ parts, vehicleId, categoryId, isLoading }: PartsPanelProps) {
|
||||
const { highlightedGroup, selectedGroup, setHighlightedGroup, setSelectedGroup } =
|
||||
useSchemaStore();
|
||||
const navigate = useNavigate();
|
||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||
const viewedKeyRef = useRef<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
// PL24 "bk. tablo:NNN-NNN" cross-reference jump. Resolved → open the target
|
||||
// illustration; unresolved (target branch not seeded yet) → pre-fill the
|
||||
// vehicle catalog search with the code so the user lands on it once seeded.
|
||||
const goToReference = useCallback(
|
||||
(ref: { code: string; categoryId: string | null }) => {
|
||||
if (!vehicleId) return;
|
||||
capture("part_reference_clicked", {
|
||||
code: ref.code,
|
||||
resolved: ref.categoryId != null,
|
||||
vehicle_id: vehicleId,
|
||||
category_id: categoryId,
|
||||
});
|
||||
if (ref.categoryId) {
|
||||
navigate({
|
||||
to: "/dashboard/vehicles/$id/categories/$categoryId",
|
||||
params: { id: vehicleId, categoryId: ref.categoryId },
|
||||
});
|
||||
} else {
|
||||
navigate({
|
||||
to: "/dashboard/vehicles/$id",
|
||||
params: { id: vehicleId },
|
||||
search: { q: ref.code },
|
||||
});
|
||||
}
|
||||
},
|
||||
[vehicleId, categoryId, navigate],
|
||||
);
|
||||
|
||||
const hasPrices = parts.some((p) => p.price != null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -144,6 +174,43 @@ export function PartsPanel({ parts, vehicleId, categoryId, isLoading }: PartsPan
|
||||
const isSelected = group != null && selectedGroup === group;
|
||||
const isUnavailable = part.unavailable === true;
|
||||
|
||||
// PL24 "bk. tablo:" cross-reference row — not a real part, but a
|
||||
// jump to another illustration. Render the target table code(s)
|
||||
// as navigable links instead of a dead "N/A" OEM cell.
|
||||
const refs = part.references;
|
||||
if (refs && refs.length > 0) {
|
||||
const label = part.name
|
||||
.replace(/bk\.\s*tablo:?/i, "")
|
||||
.replace(/\s*\/\s*$/, "")
|
||||
.trim();
|
||||
return (
|
||||
<tr key={part.id} className="border-b border-border/50 bg-muted/20">
|
||||
<td className="px-3 py-2 text-muted-foreground">{part.hotspotIndex}</td>
|
||||
<td className="px-3 py-2" colSpan={hasPrices ? 5 : 4}>
|
||||
{label && <span className="font-medium">{label}</span>}
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{refs.map((ref) => (
|
||||
<button
|
||||
key={ref.code}
|
||||
type="button"
|
||||
onClick={() => goToReference(ref)}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
title={
|
||||
ref.categoryId
|
||||
? `Tabloya git: ${ref.code}`
|
||||
: `Katalogda ara: ${ref.code}`
|
||||
}
|
||||
>
|
||||
<ArrowRight className="size-3.5 shrink-0" />
|
||||
bk. tablo: {ref.code}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={part.id}
|
||||
|
||||
@@ -15,6 +15,10 @@ export interface Part {
|
||||
price?: number | null;
|
||||
currency?: string | null;
|
||||
note?: string;
|
||||
/** PL24 "bk. tablo:NNN-NNN" cross-references. Present only on reference rows
|
||||
* (no real OEM). `categoryId` is the resolved target illustration, or null
|
||||
* when its branch isn't seeded yet → fall back to catalog search by `code`. */
|
||||
references?: Array<{ code: string; categoryId: string | null }>;
|
||||
}
|
||||
|
||||
export interface Hotspot {
|
||||
|
||||
@@ -32,6 +32,11 @@ import { useEffect, useState } from "react";
|
||||
import { KEYS_8 } from "@/lib/keys";
|
||||
export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
|
||||
component: VehicleDetailPage,
|
||||
// `q` deep-links the catalog search (e.g. from a "bk. tablo:" reference whose
|
||||
// target illustration isn't seeded yet).
|
||||
validateSearch: (search: Record<string, unknown>): { q?: string } => ({
|
||||
q: typeof search.q === "string" ? search.q : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
@@ -39,6 +44,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
function VehicleDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = Route.useParams();
|
||||
const { q: initialSearch } = Route.useSearch();
|
||||
const idValid = UUID_RE.test(id);
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||
() => getUserSettings().categoryViewMode ?? "grid",
|
||||
@@ -293,6 +299,7 @@ function VehicleDetailPage() {
|
||||
<div className={viewMode === "columns" && !searchActive ? "p-6 pb-0" : undefined}>
|
||||
<CatalogSearch
|
||||
vehicleId={id}
|
||||
initialQuery={initialSearch}
|
||||
onActiveChange={(active) => {
|
||||
setSearchActive(active);
|
||||
if (active) capture("catalog_search_opened", { vehicle_id: id });
|
||||
|
||||
Reference in New Issue
Block a user