diff --git a/apps/api/src/categories/categories.service.ts b/apps/api/src/categories/categories.service.ts index 78ab036..ed6e5a5 100644 --- a/apps/api/src/categories/categories.service.ts +++ b/apps/api/src/categories/categories.service.ts @@ -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( + 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(); + 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() diff --git a/apps/web/src/components/categories/catalog-search.tsx b/apps/web/src/components/categories/catalog-search.tsx index 17c48e0..4b8ce95 100644 --- a/apps/web/src/components/categories/catalog-search.tsx +++ b/apps/web/src/components/categories/catalog-search.tsx @@ -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: () => diff --git a/apps/web/src/components/schema/parts-panel.tsx b/apps/web/src/components/schema/parts-panel.tsx index 9a76dfe..206796b 100644 --- a/apps/web/src/components/schema/parts-panel.tsx +++ b/apps/web/src/components/schema/parts-panel.tsx @@ -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>(new Map()); const viewedKeyRef = useRef(null); const [copiedId, setCopiedId] = useState(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 ( + + {part.hotspotIndex} + + {label && {label}} + + {refs.map((ref) => ( + + ))} + + + + ); + } + return ( ; } export interface Hotspot { diff --git a/apps/web/src/routes/dashboard/vehicles_/$id/index.tsx b/apps/web/src/routes/dashboard/vehicles_/$id/index.tsx index bbefd27..438e0f2 100644 --- a/apps/web/src/routes/dashboard/vehicles_/$id/index.tsx +++ b/apps/web/src/routes/dashboard/vehicles_/$id/index.tsx @@ -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): { 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() {
{ setSearchActive(active); if (active) capture("catalog_search_opened", { vehicle_id: id });