dev #13
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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ı");
|
||||
|
||||
@@ -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>
|
||||
|
||||
151
apps/web/src/components/schema/__tests__/parts-panel.test.tsx
Normal file
151
apps/web/src/components/schema/__tests__/parts-panel.test.tsx
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -466,5 +466,8 @@
|
||||
"tr": "Türkçe",
|
||||
"en": "English",
|
||||
"switchLanguage": "Switch Language"
|
||||
},
|
||||
"categories": {
|
||||
"root": "Root"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,5 +466,8 @@
|
||||
"tr": "Türkçe",
|
||||
"en": "English",
|
||||
"switchLanguage": "Dil Değiştir"
|
||||
},
|
||||
"categories": {
|
||||
"root": "Kök"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user