dev #13

Merged
root merged 8 commits from dev into main 2026-05-14 23:54:55 +03:00
10 changed files with 493 additions and 187 deletions

View File

@@ -289,10 +289,6 @@ export class PL24Service {
const crumbs = (data.crumbs as Array<{ name: string }>) || [];
const groupName = crumbs[crumbs.length - 1]?.name || "";
const illustrationId = linkPath.match(/illustrationId=(\d+)/)?.[1] || "";
// Temporary: log raw images field to diagnose 404 issue
const rawImages = (data?.data as any)?.images || data?.images;
if (rawImages)
this.logger.log(`[imgdbg] images field: ${JSON.stringify(rawImages).substring(0, 500)}`);
const imageData = this.extractImageData(data);
let parts = this.parsePartsResponse(data);

View File

@@ -66,8 +66,8 @@ export class VehiclesController {
}
@Get(":vehicleId/prefetch-status")
async prefetchStatus(@Param("vehicleId") vehicleId: string, @CurrentUser("id") userId: string) {
await this.vehiclesService.getById(vehicleId, userId);
async prefetchStatus(@Param("vehicleId") vehicleId: string) {
await this.vehiclesService.getById(vehicleId);
return this.vehiclesService.getPrefetchStatus(vehicleId);
}
@@ -75,16 +75,15 @@ export class VehiclesController {
async getCategoryParts(
@Param("vehicleId") vehicleId: string,
@Param("categoryId") categoryId: string,
@CurrentUser("id") userId: string,
) {
// Verify vehicle belongs to user
await this.vehiclesService.getById(vehicleId, userId);
// Decoded vehicles are shared — any authenticated user can read.
await this.vehiclesService.getById(vehicleId);
return this.categoriesService.getCategoryWithParts(categoryId);
}
@Get(":id")
async getById(@Param("id") id: string, @CurrentUser("id") userId: string) {
return this.vehiclesService.getById(id, userId);
async getById(@Param("id") id: string) {
return this.vehiclesService.getById(id);
}
@Delete(":id")

View File

@@ -485,7 +485,7 @@ describe("VehiclesService", () => {
const db = createMockDb({ _selectRows: [vehicle] });
const { service } = createService(db);
const result = await service.getById("v1", "u1");
const result = await service.getById("v1");
expect(result).toEqual(vehicle);
});
@@ -493,7 +493,7 @@ describe("VehiclesService", () => {
const db = createMockDb({ _selectRows: [] });
const { service } = createService(db);
await expect(service.getById("nonexistent", "u1")).rejects.toThrow(NotFoundException);
await expect(service.getById("nonexistent")).rejects.toThrow(NotFoundException);
});
});

View File

@@ -881,9 +881,11 @@ export class VehiclesService {
}
/**
* Get vehicle by ID — verify user has access via junction table.
* Get vehicle by ID. Decoded vehicle data is shared across users — any
* authenticated user can read any vehicle. The userVehicles junction is
* used only for per-user history and delete operations.
*/
async getById(id: string, userId: string) {
async getById(id: string) {
const [result] = await this.db
.select({
id: vehicles.id,
@@ -902,8 +904,7 @@ export class VehiclesService {
updatedAt: vehicles.updatedAt,
})
.from(vehicles)
.innerJoin(userVehicles, eq(userVehicles.vehicleId, vehicles.id))
.where(and(eq(vehicles.id, id), eq(userVehicles.userId, userId)))
.where(eq(vehicles.id, id))
.limit(1);
if (!result) throw new NotFoundException("Araç bulunamadı");

View File

@@ -1,9 +1,11 @@
import { api } from "@/lib/api-client";
import { getCategoryIcon } from "@/lib/category-icons";
import { useTranslation } from "@/lib/i18n";
import { Card, CardContent } from "@sase/ui";
import { Link } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { ChevronRight, Loader2 } from "lucide-react";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
interface Category {
id: string;
@@ -16,6 +18,12 @@ interface Category {
source?: string;
}
interface StackEntry {
id: string | null;
name: string;
categories: Category[];
}
interface CategoryGridProps {
categories: Category[];
vehicleId: string;
@@ -33,16 +41,37 @@ export function CategoryGrid({
parentId,
variantSearch,
}: CategoryGridProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [stack, setStack] = useState<StackEntry[]>([
{ id: parentId ?? null, name: t("categories.root"), categories },
]);
const [loadingId, setLoadingId] = useState<string | null>(null);
const [prefetchingIds, setPrefetchingIds] = useState<Set<string>>(new Set());
const [imageOverrides, setImageOverrides] = useState<Map<string, string>>(new Map());
const prefetchedRef = useRef<Set<string>>(new Set());
// Reset stack when input categories prop changes (parent navigation).
// `t` is intentionally excluded: useTranslation returns a fresh function each
// render, so including it would re-fire the effect on every render and reset
// the stack immediately after handleSelect pushes a child level — making
// parent-category clicks appear to do nothing.
// biome-ignore lint/correctness/useExhaustiveDependencies: t identity is unstable; key is static
useEffect(() => {
setStack([{ id: parentId ?? null, name: t("categories.root"), categories }]);
}, [categories, parentId]);
const current = stack[stack.length - 1];
const currentCategories = current.categories;
// Prefetch schema images for leaf categories in batches of 2
useEffect(() => {
prefetchedRef.current.clear();
setImageOverrides(new Map());
const leafsWithoutImage = categories.filter(
const leafsWithoutImage = currentCategories.filter(
(c) =>
c.children !== undefined &&
c.children.length === 0 &&
@@ -55,7 +84,7 @@ export function CategoryGrid({
return;
}
const parentId = categories[0]?.parentId;
const firstParentId = currentCategories[0]?.parentId;
let didCancel = false;
const BATCH_SIZE = 2;
@@ -71,9 +100,9 @@ export function CategoryGrid({
for (const c of batch) prefetchedRef.current.add(c.id);
if (!didCancel && parentId) {
if (!didCancel && firstParentId) {
try {
const refreshed = await api.get<Category[]>(`/categories/${parentId}/children`);
const refreshed = await api.get<Category[]>(`/categories/${firstParentId}/children`);
if (!didCancel && refreshed?.length) {
setImageOverrides((prev) => {
const next = new Map(prev);
@@ -92,46 +121,107 @@ export function CategoryGrid({
return () => {
didCancel = true;
};
}, [categories, vehicleId]);
}, [currentCategories, vehicleId]);
const navigateToLeaf = useCallback(
(categoryId: string) => {
navigate({
to: catalogMode
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
: "/dashboard/vehicles/$id/categories/$categoryId",
params: catalogMode
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId }
: { id: vehicleId, categoryId },
search: (catalogMode && variantSearch ? variantSearch : undefined) as any,
});
},
[navigate, catalogMode, brandName, vehicleId, variantSearch],
);
const handleSelect = useCallback(
async (category: Category) => {
const known = category.children;
if (known && known.length > 0) {
setStack((s) => [...s, { id: category.id, name: category.name, categories: known }]);
return;
}
if (category.children !== undefined && category.children.length === 0) {
navigateToLeaf(category.id);
return;
}
setLoadingId(category.id);
try {
const data = await queryClient.fetchQuery({
queryKey: ["category-children", category.id],
queryFn: () => api.get<Category[]>(`/categories/${category.id}/children`),
staleTime: 5 * 60 * 1000,
});
const children = data ?? [];
if (children.length === 0) {
navigateToLeaf(category.id);
return;
}
setStack((s) => [...s, { id: category.id, name: category.name, categories: children }]);
} finally {
setLoadingId(null);
}
},
[queryClient, navigateToLeaf],
);
if (!categories || categories.length === 0) {
return <p className="py-4 text-center text-sm text-muted-foreground">Kategori bulunamadi.</p>;
return (
<p className="py-4 text-center text-sm text-muted-foreground">{t("catalog.noCategories")}</p>
);
}
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{categories.map((category) => {
const Icon = getCategoryIcon(category.name);
const isLeaf = category.children !== undefined && category.children.length === 0;
const schemaImageUrl = imageOverrides.get(category.id) || category.schemaImageUrl;
<div>
{stack.length > 1 && (
<nav className="flex items-center gap-1 text-sm text-muted-foreground mb-3">
{stack.map((s, i) => (
<Fragment key={s.id ?? "root"}>
{i > 0 && <ChevronRight className="h-3.5 w-3.5" />}
<button
type="button"
onClick={() => setStack((prev) => prev.slice(0, i + 1))}
className="hover:text-foreground hover:underline truncate max-w-[200px] disabled:hover:no-underline"
disabled={i === stack.length - 1}
>
{s.name}
</button>
</Fragment>
))}
</nav>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{currentCategories.map((category) => {
const Icon = getCategoryIcon(category.name);
const isLeaf = category.children !== undefined && category.children.length === 0;
const schemaImageUrl = imageOverrides.get(category.id) || category.schemaImageUrl;
const isSelectLoading = loadingId === category.id;
return (
<Link
key={category.id}
to={
catalogMode
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
: "/dashboard/vehicles/$id/categories/$categoryId"
}
params={
catalogMode
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
: { id: vehicleId, categoryId: category.id }
}
search={catalogMode && variantSearch ? variantSearch : undefined}
className={category.unavailable ? "opacity-40" : undefined}
>
<CategoryCard
name={category.name}
partCount={category.partCount}
Icon={Icon}
schemaImageUrl={schemaImageUrl}
isLeaf={isLeaf}
isLoading={prefetchingIds.has(category.id)}
/>
</Link>
);
})}
return (
<button
key={category.id}
type="button"
onClick={() => handleSelect(category)}
disabled={category.unavailable || isSelectLoading}
className={`w-full text-left ${category.unavailable ? "opacity-40" : ""}`}
>
<CategoryCard
name={category.name}
partCount={category.partCount}
Icon={Icon}
schemaImageUrl={schemaImageUrl}
isLeaf={isLeaf}
isLoading={prefetchingIds.has(category.id) || isSelectLoading}
/>
</button>
);
})}
</div>
</div>
);
}
@@ -198,7 +288,11 @@ function CategoryCard({
<p className="text-sm text-muted-foreground">{partCount} parça</p>
)}
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all flex-shrink-0" />
{isLoading ? (
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all flex-shrink-0" />
)}
</div>
</CardContent>
</Card>

View File

@@ -0,0 +1,151 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Part } from "@/hooks/use-parts";
const captureMock = vi.fn();
const setSelectedGroupMock = vi.fn();
const setHighlightedGroupMock = vi.fn();
vi.mock("@/lib/posthog", () => ({
capture: (...args: unknown[]) => captureMock(...args),
}));
vi.mock("@/lib/api-client", () => ({
api: {
post: vi.fn().mockResolvedValue({}),
get: vi.fn().mockResolvedValue({}),
},
}));
vi.mock("@/stores/schema.store", () => ({
useSchemaStore: () => ({
highlightedGroup: null,
selectedGroup: null,
setHighlightedGroup: setHighlightedGroupMock,
setSelectedGroup: setSelectedGroupMock,
}),
}));
import { PartsPanel } from "../parts-panel";
const buildPart = (overrides: Partial<Part> = {}): Part => ({
id: overrides.id ?? "p1",
name: overrides.name ?? "Part 1",
oemCode: overrides.oemCode ?? "OEM-1",
quantity: overrides.quantity ?? 1,
position: overrides.position ?? "Front",
hotspotIndex: overrides.hotspotIndex ?? 1,
unavailable: overrides.unavailable,
remark: overrides.remark,
modelCodes: overrides.modelCodes,
presel: overrides.presel,
price: overrides.price,
currency: overrides.currency,
note: overrides.note,
});
beforeEach(() => {
captureMock.mockClear();
setSelectedGroupMock.mockClear();
setHighlightedGroupMock.mockClear();
});
afterEach(() => {
cleanup();
});
describe("PartsPanel", () => {
it("renders 8 skeleton rows and skips telemetry when isLoading", () => {
const { container } = render(<PartsPanel parts={[]} isLoading />);
const skeletons = container.querySelectorAll(".h-10.w-full");
expect(skeletons.length).toBe(8);
const rows = container.querySelectorAll('[data-faro-user-action-name="select-part"]');
expect(rows.length).toBe(0);
expect(captureMock).not.toHaveBeenCalled();
});
it("does not invoke store setters when an unavailable row is clicked", () => {
const parts: Part[] = [
buildPart({ id: "available", name: "Available Part", hotspotIndex: 1 }),
buildPart({
id: "unavailable",
name: "Unavailable Part",
hotspotIndex: 2,
unavailable: true,
}),
];
const { container } = render(<PartsPanel parts={parts} />);
const rows = container.querySelectorAll('[data-faro-user-action-name="select-part"]');
expect(rows.length).toBe(2);
const unavailableRow = rows[1] as HTMLElement;
fireEvent.click(unavailableRow);
fireEvent.mouseEnter(unavailableRow);
fireEvent.mouseLeave(unavailableRow);
expect(setSelectedGroupMock).not.toHaveBeenCalled();
expect(setHighlightedGroupMock).not.toHaveBeenCalled();
});
it("applies the correct ARIA and tabIndex to unavailable vs available rows", () => {
const parts: Part[] = [
buildPart({ id: "available", name: "Available Part", hotspotIndex: 1 }),
buildPart({
id: "unavailable",
name: "Unavailable Part",
hotspotIndex: 2,
unavailable: true,
}),
];
const { container } = render(<PartsPanel parts={parts} />);
const rows = container.querySelectorAll('[data-faro-user-action-name="select-part"]');
const availableRow = rows[0] as HTMLElement;
const unavailableRow = rows[1] as HTMLElement;
expect(unavailableRow.getAttribute("aria-disabled")).toBe("true");
expect(unavailableRow.getAttribute("tabindex")).toBe("-1");
expect(availableRow.getAttribute("tabindex")).toBe("0");
expect(availableRow.getAttribute("aria-disabled")).toBeNull();
});
it("renders a Geri don button in the empty state that invokes window.history.back", () => {
const backSpy = vi.spyOn(window.history, "back").mockImplementation(() => {});
render(<PartsPanel parts={[]} />);
const button = screen.getByRole("button", { name: "Geri don" });
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(backSpy).toHaveBeenCalledTimes(1);
backSpy.mockRestore();
});
it("gates parts_panel_viewed on isLoading and fires once after load", () => {
const parts: Part[] = [
buildPart({ id: "a", hotspotIndex: 1 }),
buildPart({ id: "b", hotspotIndex: 2 }),
];
const { rerender } = render(
<PartsPanel parts={parts} vehicleId="v1" categoryId="c1" isLoading />,
);
expect(captureMock).not.toHaveBeenCalled();
rerender(<PartsPanel parts={parts} vehicleId="v1" categoryId="c1" isLoading={false} />);
expect(captureMock).toHaveBeenCalledTimes(1);
const [eventName, payload] = captureMock.mock.calls[0];
expect(eventName).toBe("parts_panel_viewed");
expect(payload).toMatchObject({
vehicle_id: "v1",
category_id: "c1",
parts_count: parts.length,
});
});
});

View File

@@ -2,7 +2,7 @@ import type { Part } from "@/hooks/use-parts";
import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
import { useSchemaStore } from "@/stores/schema.store";
import { cn } from "@sase/ui";
import { Button, Skeleton, cn } from "@sase/ui";
import { Check, Copy } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -10,9 +10,12 @@ interface PartsPanelProps {
parts: Part[];
vehicleId?: string;
categoryId?: string;
isLoading?: boolean;
}
export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
const SKELETON_ROW_KEYS = ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7"] as const;
export function PartsPanel({ parts, vehicleId, categoryId, isLoading }: PartsPanelProps) {
const { highlightedGroup, selectedGroup, setHighlightedGroup, setSelectedGroup } =
useSchemaStore();
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
@@ -22,6 +25,7 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
const hasPrices = parts.some((p) => p.price != null);
useEffect(() => {
if (isLoading) return;
const key = `${vehicleId ?? ""}|${categoryId ?? ""}`;
if (viewedKeyRef.current === key) return;
viewedKeyRef.current = key;
@@ -34,7 +38,7 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
parts.filter((p) => p.hotspotIndex != null && !p.unavailable).map((p) => p.hotspotIndex),
).size,
});
}, [vehicleId, categoryId, parts.length, hasPrices, parts]);
}, [vehicleId, categoryId, parts.length, hasPrices, parts, isLoading]);
// Map group → IDs of available (non-unavailable) parts
const availableByGroup = useMemo(() => {
@@ -84,6 +88,24 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
}
}, [selectedGroup]);
if (isLoading) {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">Parcalar</h3>
<p className="text-xs text-muted-foreground">Yukleniyor...</p>
</div>
<div className="flex-1 overflow-y-auto">
<div className="space-y-2 p-3">
{SKELETON_ROW_KEYS.map((k) => (
<Skeleton key={k} className="h-10 w-full" />
))}
</div>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="border-b border-border px-4 py-3">
@@ -92,106 +114,122 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
</div>
<div className="flex-1 overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-background">
<tr className="border-b border-border text-left text-xs font-medium text-muted-foreground">
<th className="px-3 py-2 w-10">#</th>
<th className="px-3 py-2">Parca Adi</th>
<th className="px-3 py-2">OEM Kodu</th>
<th className="px-3 py-2 w-14 text-center">Adet</th>
<th className="px-3 py-2">Pozisyon</th>
{hasPrices && <th className="px-3 py-2 text-right">Fiyat</th>}
</tr>
</thead>
<tbody>
{parts.map((part) => {
const group = part.hotspotIndex;
const isHighlighted = group != null && highlightedGroup === group;
const isSelected = group != null && selectedGroup === group;
{parts.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center text-sm text-muted-foreground">
<p>Bu kategori icin parca bulunamadi.</p>
<Button type="button" variant="outline" onClick={() => window.history.back()}>
Geri don
</Button>
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-background">
<tr className="border-b border-border text-left text-xs font-medium text-muted-foreground">
<th className="px-3 py-2 w-10">#</th>
<th className="px-3 py-2">Parca Adi</th>
<th className="px-3 py-2">OEM Kodu</th>
<th className="px-3 py-2 w-14 text-center">Adet</th>
<th className="px-3 py-2">Pozisyon</th>
{hasPrices && <th className="px-3 py-2 text-right">Fiyat</th>}
</tr>
</thead>
<tbody>
{parts.map((part) => {
const group = part.hotspotIndex;
const isHighlighted = group != null && highlightedGroup === group;
const isSelected = group != null && selectedGroup === group;
const isUnavailable = part.unavailable === true;
return (
<tr
key={part.id}
data-faro-user-action-name="select-part"
ref={(el) => {
if (group == null || !el) return;
const availableIds = availableByGroup.get(group);
if (availableIds?.length === 1) {
// Single available part — scroll directly to it
if (part.id === availableIds[0]) {
return (
<tr
key={part.id}
data-faro-user-action-name="select-part"
aria-disabled={isUnavailable ? "true" : undefined}
role={isUnavailable ? undefined : "button"}
tabIndex={isUnavailable ? -1 : 0}
ref={(el) => {
if (group == null || !el) return;
const availableIds = availableByGroup.get(group);
if (availableIds?.length === 1) {
// Single available part — scroll directly to it
if (part.id === availableIds[0]) {
rowRefs.current.set(group, el);
}
} else if (!rowRefs.current.has(group)) {
// Multiple or zero available — first part in group
rowRefs.current.set(group, el);
}
} else if (!rowRefs.current.has(group)) {
// Multiple or zero available — first part in group
rowRefs.current.set(group, el);
}
}}
className={cn(
"cursor-pointer border-b border-border/50 transition-colors duration-150",
part.unavailable && "opacity-40",
isSelected && "bg-primary/10 ring-1 ring-inset ring-primary/20",
isHighlighted && !isSelected && "bg-accent",
!isSelected && !isHighlighted && "hover:bg-accent/50",
)}
onMouseEnter={() => setHighlightedGroup(group)}
onMouseLeave={() => setHighlightedGroup(null)}
onClick={() => setSelectedGroup(selectedGroup === group ? null : group)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedGroup(selectedGroup === group ? null : group);
}
}}
>
<td className="px-3 py-2 text-muted-foreground">{part.hotspotIndex}</td>
<td className="px-3 py-2">
<span className="font-medium">{part.name}</span>
{(part.remark || part.modelCodes) && (
<span className="block text-xs text-muted-foreground">
{[part.remark, part.modelCodes].filter(Boolean).join(" · ")}
</span>
}}
className={cn(
"border-b border-border/50 transition-colors duration-150",
!isUnavailable && "cursor-pointer",
isUnavailable && "opacity-40",
isSelected && "bg-primary/10 ring-1 ring-inset ring-primary/20",
isHighlighted && !isSelected && "bg-accent",
!isUnavailable && !isSelected && !isHighlighted && "hover:bg-accent/50",
)}
</td>
<td className="px-3 py-2 font-mono text-xs">
<span className="inline-flex items-center gap-1">
{part.oemCode && (
<button
type="button"
className="inline-flex shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground"
onClick={(e) => copyOemCode(e, part.id, part.oemCode)}
>
{copiedId === part.id ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
</button>
onMouseEnter={isUnavailable ? undefined : () => setHighlightedGroup(group)}
onMouseLeave={isUnavailable ? undefined : () => setHighlightedGroup(null)}
onClick={
isUnavailable
? undefined
: () => setSelectedGroup(selectedGroup === group ? null : group)
}
onKeyDown={
isUnavailable
? undefined
: (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedGroup(selectedGroup === group ? null : group);
}
}
}
>
<td className="px-3 py-2 text-muted-foreground">{part.hotspotIndex}</td>
<td className="px-3 py-2">
<span className="font-medium">{part.name}</span>
{(part.remark || part.modelCodes) && (
<span className="block text-xs text-muted-foreground">
{[part.remark, part.modelCodes].filter(Boolean).join(" · ")}
</span>
)}
{part.oemCode}
</span>
</td>
<td className="px-3 py-2 text-center">{part.quantity}</td>
<td className="px-3 py-2 text-muted-foreground">{part.position}</td>
{hasPrices && (
<td className="px-3 py-2 text-right text-xs">
{part.price != null
? new Intl.NumberFormat("de-DE", {
style: "currency",
currency: part.currency ?? "EUR",
}).format(part.price)
: "—"}
</td>
)}
</tr>
);
})}
</tbody>
</table>
{parts.length === 0 && (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
Bu kategori icin parca bulunamadi.
</div>
<td className="px-3 py-2 font-mono text-xs">
<span className="inline-flex items-center gap-1">
{part.oemCode && (
<button
type="button"
className="inline-flex shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground"
onClick={(e) => copyOemCode(e, part.id, part.oemCode)}
>
{copiedId === part.id ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
</button>
)}
{part.oemCode}
</span>
</td>
<td className="px-3 py-2 text-center">{part.quantity}</td>
<td className="px-3 py-2 text-muted-foreground">{part.position}</td>
{hasPrices && (
<td className="px-3 py-2 text-right text-xs">
{part.price != null
? new Intl.NumberFormat("de-DE", {
style: "currency",
currency: part.currency ?? "EUR",
}).format(part.price)
: "—"}
</td>
)}
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>

View File

@@ -85,6 +85,28 @@ export function SchemaViewer({
);
}
// PL24 "bomOverviewlist" widget pages (PR-code-filtered overviews) return
// parts but no illustration. Drop the schema panel entirely in that case
// so the parts list takes full width instead of staring at a sad fallback.
if (!schemaPic) {
return (
<div
ref={containerRef}
className={cn(
"rounded-lg border border-border bg-background",
isFullscreen ? "fixed inset-0 z-50 rounded-none" : "md:h-[700px]",
)}
>
<PartsPanel
parts={parts}
vehicleId={vehicleId}
categoryId={categoryId}
isLoading={isLoading}
/>
</div>
);
}
return (
<div
ref={containerRef}
@@ -112,44 +134,43 @@ export function SchemaViewer({
onTouchMove={interaction.onTouchMove}
onTouchEnd={interaction.onTouchEnd}
>
{schemaPic ? (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
transform: `scale(${zoom}) translate(${panX / zoom}px, ${panY / zoom}px)`,
transformOrigin: "center center",
willChange: "transform",
}}
>
<div className="relative">
<img
src={schemaPic.url}
alt={schemaPic.label || "Şema görüntüsü"}
width={schemaPic.width > 0 ? schemaPic.width : undefined}
height={schemaPic.height > 0 ? schemaPic.height : undefined}
className="max-h-full max-w-full select-none object-contain"
draggable={false}
<div
className="absolute inset-0 flex items-center justify-center"
style={{
transform: `scale(${zoom}) translate(${panX / zoom}px, ${panY / zoom}px)`,
transformOrigin: "center center",
willChange: "transform",
}}
>
<div className="relative">
<img
src={schemaPic.url}
alt={schemaPic.label || "Şema görüntüsü"}
width={schemaPic.width > 0 ? schemaPic.width : undefined}
height={schemaPic.height > 0 ? schemaPic.height : undefined}
className="max-h-full max-w-full select-none object-contain"
draggable={false}
/>
{schemaPic.width > 0 && schemaPic.height > 0 && (
<HotspotOverlay
hotspots={hotspots}
imageWidth={schemaPic.width}
imageHeight={schemaPic.height}
/>
{schemaPic.width > 0 && schemaPic.height > 0 && (
<HotspotOverlay
hotspots={hotspots}
imageWidth={schemaPic.width}
imageHeight={schemaPic.height}
/>
)}
</div>
)}
</div>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Sema goruntusu bulunamadi
</div>
)}
</div>
</div>
</div>
{/* Right side: Parts panel (40%) */}
<div className="max-h-[500px] w-full md:max-h-none md:w-[40%]">
<PartsPanel parts={parts} vehicleId={vehicleId} categoryId={categoryId} />
<PartsPanel
parts={parts}
vehicleId={vehicleId}
categoryId={categoryId}
isLoading={isLoading}
/>
</div>
</div>
);

View File

@@ -466,5 +466,8 @@
"tr": "Türkçe",
"en": "English",
"switchLanguage": "Switch Language"
},
"categories": {
"root": "Root"
}
}

View File

@@ -466,5 +466,8 @@
"tr": "Türkçe",
"en": "English",
"switchLanguage": "Dil Değiştir"
},
"categories": {
"root": "Kök"
}
}