dev #13
@@ -289,10 +289,6 @@ export class PL24Service {
|
|||||||
const crumbs = (data.crumbs as Array<{ name: string }>) || [];
|
const crumbs = (data.crumbs as Array<{ name: string }>) || [];
|
||||||
const groupName = crumbs[crumbs.length - 1]?.name || "";
|
const groupName = crumbs[crumbs.length - 1]?.name || "";
|
||||||
const illustrationId = linkPath.match(/illustrationId=(\d+)/)?.[1] || "";
|
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);
|
const imageData = this.extractImageData(data);
|
||||||
|
|
||||||
let parts = this.parsePartsResponse(data);
|
let parts = this.parsePartsResponse(data);
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ export class VehiclesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(":vehicleId/prefetch-status")
|
@Get(":vehicleId/prefetch-status")
|
||||||
async prefetchStatus(@Param("vehicleId") vehicleId: string, @CurrentUser("id") userId: string) {
|
async prefetchStatus(@Param("vehicleId") vehicleId: string) {
|
||||||
await this.vehiclesService.getById(vehicleId, userId);
|
await this.vehiclesService.getById(vehicleId);
|
||||||
return this.vehiclesService.getPrefetchStatus(vehicleId);
|
return this.vehiclesService.getPrefetchStatus(vehicleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,16 +75,15 @@ export class VehiclesController {
|
|||||||
async getCategoryParts(
|
async getCategoryParts(
|
||||||
@Param("vehicleId") vehicleId: string,
|
@Param("vehicleId") vehicleId: string,
|
||||||
@Param("categoryId") categoryId: string,
|
@Param("categoryId") categoryId: string,
|
||||||
@CurrentUser("id") userId: string,
|
|
||||||
) {
|
) {
|
||||||
// Verify vehicle belongs to user
|
// Decoded vehicles are shared — any authenticated user can read.
|
||||||
await this.vehiclesService.getById(vehicleId, userId);
|
await this.vehiclesService.getById(vehicleId);
|
||||||
return this.categoriesService.getCategoryWithParts(categoryId);
|
return this.categoriesService.getCategoryWithParts(categoryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
async getById(@Param("id") id: string, @CurrentUser("id") userId: string) {
|
async getById(@Param("id") id: string) {
|
||||||
return this.vehiclesService.getById(id, userId);
|
return this.vehiclesService.getById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
|
|||||||
@@ -485,7 +485,7 @@ describe("VehiclesService", () => {
|
|||||||
const db = createMockDb({ _selectRows: [vehicle] });
|
const db = createMockDb({ _selectRows: [vehicle] });
|
||||||
const { service } = createService(db);
|
const { service } = createService(db);
|
||||||
|
|
||||||
const result = await service.getById("v1", "u1");
|
const result = await service.getById("v1");
|
||||||
expect(result).toEqual(vehicle);
|
expect(result).toEqual(vehicle);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -493,7 +493,7 @@ describe("VehiclesService", () => {
|
|||||||
const db = createMockDb({ _selectRows: [] });
|
const db = createMockDb({ _selectRows: [] });
|
||||||
const { service } = createService(db);
|
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
|
const [result] = await this.db
|
||||||
.select({
|
.select({
|
||||||
id: vehicles.id,
|
id: vehicles.id,
|
||||||
@@ -902,8 +904,7 @@ export class VehiclesService {
|
|||||||
updatedAt: vehicles.updatedAt,
|
updatedAt: vehicles.updatedAt,
|
||||||
})
|
})
|
||||||
.from(vehicles)
|
.from(vehicles)
|
||||||
.innerJoin(userVehicles, eq(userVehicles.vehicleId, vehicles.id))
|
.where(eq(vehicles.id, id))
|
||||||
.where(and(eq(vehicles.id, id), eq(userVehicles.userId, userId)))
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!result) throw new NotFoundException("Araç bulunamadı");
|
if (!result) throw new NotFoundException("Araç bulunamadı");
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { getCategoryIcon } from "@/lib/category-icons";
|
import { getCategoryIcon } from "@/lib/category-icons";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { Card, CardContent } from "@sase/ui";
|
import { Card, CardContent } from "@sase/ui";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { ChevronRight, Loader2 } from "lucide-react";
|
||||||
|
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,6 +18,12 @@ interface Category {
|
|||||||
source?: string;
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StackEntry {
|
||||||
|
id: string | null;
|
||||||
|
name: string;
|
||||||
|
categories: Category[];
|
||||||
|
}
|
||||||
|
|
||||||
interface CategoryGridProps {
|
interface CategoryGridProps {
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
@@ -33,16 +41,37 @@ export function CategoryGrid({
|
|||||||
parentId,
|
parentId,
|
||||||
variantSearch,
|
variantSearch,
|
||||||
}: CategoryGridProps) {
|
}: 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 [prefetchingIds, setPrefetchingIds] = useState<Set<string>>(new Set());
|
||||||
const [imageOverrides, setImageOverrides] = useState<Map<string, string>>(new Map());
|
const [imageOverrides, setImageOverrides] = useState<Map<string, string>>(new Map());
|
||||||
const prefetchedRef = useRef<Set<string>>(new Set());
|
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
|
// Prefetch schema images for leaf categories in batches of 2
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
prefetchedRef.current.clear();
|
prefetchedRef.current.clear();
|
||||||
setImageOverrides(new Map());
|
setImageOverrides(new Map());
|
||||||
|
|
||||||
const leafsWithoutImage = categories.filter(
|
const leafsWithoutImage = currentCategories.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.children !== undefined &&
|
c.children !== undefined &&
|
||||||
c.children.length === 0 &&
|
c.children.length === 0 &&
|
||||||
@@ -55,7 +84,7 @@ export function CategoryGrid({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentId = categories[0]?.parentId;
|
const firstParentId = currentCategories[0]?.parentId;
|
||||||
let didCancel = false;
|
let didCancel = false;
|
||||||
|
|
||||||
const BATCH_SIZE = 2;
|
const BATCH_SIZE = 2;
|
||||||
@@ -71,9 +100,9 @@ export function CategoryGrid({
|
|||||||
|
|
||||||
for (const c of batch) prefetchedRef.current.add(c.id);
|
for (const c of batch) prefetchedRef.current.add(c.id);
|
||||||
|
|
||||||
if (!didCancel && parentId) {
|
if (!didCancel && firstParentId) {
|
||||||
try {
|
try {
|
||||||
const refreshed = await api.get<Category[]>(`/categories/${parentId}/children`);
|
const refreshed = await api.get<Category[]>(`/categories/${firstParentId}/children`);
|
||||||
if (!didCancel && refreshed?.length) {
|
if (!didCancel && refreshed?.length) {
|
||||||
setImageOverrides((prev) => {
|
setImageOverrides((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -92,46 +121,107 @@ export function CategoryGrid({
|
|||||||
return () => {
|
return () => {
|
||||||
didCancel = true;
|
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) {
|
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 (
|
return (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div>
|
||||||
{categories.map((category) => {
|
{stack.length > 1 && (
|
||||||
const Icon = getCategoryIcon(category.name);
|
<nav className="flex items-center gap-1 text-sm text-muted-foreground mb-3">
|
||||||
const isLeaf = category.children !== undefined && category.children.length === 0;
|
{stack.map((s, i) => (
|
||||||
const schemaImageUrl = imageOverrides.get(category.id) || category.schemaImageUrl;
|
<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 (
|
return (
|
||||||
<Link
|
<button
|
||||||
key={category.id}
|
key={category.id}
|
||||||
to={
|
type="button"
|
||||||
catalogMode
|
onClick={() => handleSelect(category)}
|
||||||
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
|
disabled={category.unavailable || isSelectLoading}
|
||||||
: "/dashboard/vehicles/$id/categories/$categoryId"
|
className={`w-full text-left ${category.unavailable ? "opacity-40" : ""}`}
|
||||||
}
|
>
|
||||||
params={
|
<CategoryCard
|
||||||
catalogMode
|
name={category.name}
|
||||||
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
|
partCount={category.partCount}
|
||||||
: { id: vehicleId, categoryId: category.id }
|
Icon={Icon}
|
||||||
}
|
schemaImageUrl={schemaImageUrl}
|
||||||
search={catalogMode && variantSearch ? variantSearch : undefined}
|
isLeaf={isLeaf}
|
||||||
className={category.unavailable ? "opacity-40" : undefined}
|
isLoading={prefetchingIds.has(category.id) || isSelectLoading}
|
||||||
>
|
/>
|
||||||
<CategoryCard
|
</button>
|
||||||
name={category.name}
|
);
|
||||||
partCount={category.partCount}
|
})}
|
||||||
Icon={Icon}
|
</div>
|
||||||
schemaImageUrl={schemaImageUrl}
|
|
||||||
isLeaf={isLeaf}
|
|
||||||
isLoading={prefetchingIds.has(category.id)}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -198,7 +288,11 @@ function CategoryCard({
|
|||||||
<p className="text-sm text-muted-foreground">{partCount} parça</p>
|
<p className="text-sm text-muted-foreground">{partCount} parça</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 { api } from "@/lib/api-client";
|
||||||
import { capture } from "@/lib/posthog";
|
import { capture } from "@/lib/posthog";
|
||||||
import { useSchemaStore } from "@/stores/schema.store";
|
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 { Check, Copy } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
@@ -10,9 +10,12 @@ interface PartsPanelProps {
|
|||||||
parts: Part[];
|
parts: Part[];
|
||||||
vehicleId?: string;
|
vehicleId?: string;
|
||||||
categoryId?: 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 } =
|
const { highlightedGroup, selectedGroup, setHighlightedGroup, setSelectedGroup } =
|
||||||
useSchemaStore();
|
useSchemaStore();
|
||||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
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);
|
const hasPrices = parts.some((p) => p.price != null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isLoading) return;
|
||||||
const key = `${vehicleId ?? ""}|${categoryId ?? ""}`;
|
const key = `${vehicleId ?? ""}|${categoryId ?? ""}`;
|
||||||
if (viewedKeyRef.current === key) return;
|
if (viewedKeyRef.current === key) return;
|
||||||
viewedKeyRef.current = key;
|
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),
|
parts.filter((p) => p.hotspotIndex != null && !p.unavailable).map((p) => p.hotspotIndex),
|
||||||
).size,
|
).size,
|
||||||
});
|
});
|
||||||
}, [vehicleId, categoryId, parts.length, hasPrices, parts]);
|
}, [vehicleId, categoryId, parts.length, hasPrices, parts, isLoading]);
|
||||||
|
|
||||||
// Map group → IDs of available (non-unavailable) parts
|
// Map group → IDs of available (non-unavailable) parts
|
||||||
const availableByGroup = useMemo(() => {
|
const availableByGroup = useMemo(() => {
|
||||||
@@ -84,6 +88,24 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
|
|||||||
}
|
}
|
||||||
}, [selectedGroup]);
|
}, [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 (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
<div className="border-b border-border px-4 py-3">
|
<div className="border-b border-border px-4 py-3">
|
||||||
@@ -92,106 +114,122 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<table className="w-full text-sm">
|
{parts.length === 0 ? (
|
||||||
<thead className="sticky top-0 z-10 bg-background">
|
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center text-sm text-muted-foreground">
|
||||||
<tr className="border-b border-border text-left text-xs font-medium text-muted-foreground">
|
<p>Bu kategori icin parca bulunamadi.</p>
|
||||||
<th className="px-3 py-2 w-10">#</th>
|
<Button type="button" variant="outline" onClick={() => window.history.back()}>
|
||||||
<th className="px-3 py-2">Parca Adi</th>
|
Geri don
|
||||||
<th className="px-3 py-2">OEM Kodu</th>
|
</Button>
|
||||||
<th className="px-3 py-2 w-14 text-center">Adet</th>
|
</div>
|
||||||
<th className="px-3 py-2">Pozisyon</th>
|
) : (
|
||||||
{hasPrices && <th className="px-3 py-2 text-right">Fiyat</th>}
|
<table className="w-full text-sm">
|
||||||
</tr>
|
<thead className="sticky top-0 z-10 bg-background">
|
||||||
</thead>
|
<tr className="border-b border-border text-left text-xs font-medium text-muted-foreground">
|
||||||
<tbody>
|
<th className="px-3 py-2 w-10">#</th>
|
||||||
{parts.map((part) => {
|
<th className="px-3 py-2">Parca Adi</th>
|
||||||
const group = part.hotspotIndex;
|
<th className="px-3 py-2">OEM Kodu</th>
|
||||||
const isHighlighted = group != null && highlightedGroup === group;
|
<th className="px-3 py-2 w-14 text-center">Adet</th>
|
||||||
const isSelected = group != null && selectedGroup === group;
|
<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 (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={part.id}
|
key={part.id}
|
||||||
data-faro-user-action-name="select-part"
|
data-faro-user-action-name="select-part"
|
||||||
ref={(el) => {
|
aria-disabled={isUnavailable ? "true" : undefined}
|
||||||
if (group == null || !el) return;
|
role={isUnavailable ? undefined : "button"}
|
||||||
const availableIds = availableByGroup.get(group);
|
tabIndex={isUnavailable ? -1 : 0}
|
||||||
if (availableIds?.length === 1) {
|
ref={(el) => {
|
||||||
// Single available part — scroll directly to it
|
if (group == null || !el) return;
|
||||||
if (part.id === availableIds[0]) {
|
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);
|
rowRefs.current.set(group, el);
|
||||||
}
|
}
|
||||||
} else if (!rowRefs.current.has(group)) {
|
}}
|
||||||
// Multiple or zero available — first part in group
|
className={cn(
|
||||||
rowRefs.current.set(group, el);
|
"border-b border-border/50 transition-colors duration-150",
|
||||||
}
|
!isUnavailable && "cursor-pointer",
|
||||||
}}
|
isUnavailable && "opacity-40",
|
||||||
className={cn(
|
isSelected && "bg-primary/10 ring-1 ring-inset ring-primary/20",
|
||||||
"cursor-pointer border-b border-border/50 transition-colors duration-150",
|
isHighlighted && !isSelected && "bg-accent",
|
||||||
part.unavailable && "opacity-40",
|
!isUnavailable && !isSelected && !isHighlighted && "hover:bg-accent/50",
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
onMouseEnter={isUnavailable ? undefined : () => setHighlightedGroup(group)}
|
||||||
<td className="px-3 py-2 font-mono text-xs">
|
onMouseLeave={isUnavailable ? undefined : () => setHighlightedGroup(null)}
|
||||||
<span className="inline-flex items-center gap-1">
|
onClick={
|
||||||
{part.oemCode && (
|
isUnavailable
|
||||||
<button
|
? undefined
|
||||||
type="button"
|
: () => setSelectedGroup(selectedGroup === group ? null : group)
|
||||||
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)}
|
onKeyDown={
|
||||||
>
|
isUnavailable
|
||||||
{copiedId === part.id ? (
|
? undefined
|
||||||
<Check className="size-3.5 text-green-500" />
|
: (e) => {
|
||||||
) : (
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
<Copy className="size-3.5" />
|
e.preventDefault();
|
||||||
)}
|
setSelectedGroup(selectedGroup === group ? null : group);
|
||||||
</button>
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
</td>
|
||||||
)}
|
<td className="px-3 py-2 font-mono text-xs">
|
||||||
</tr>
|
<span className="inline-flex items-center gap-1">
|
||||||
);
|
{part.oemCode && (
|
||||||
})}
|
<button
|
||||||
</tbody>
|
type="button"
|
||||||
</table>
|
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)}
|
||||||
{parts.length === 0 && (
|
>
|
||||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
{copiedId === part.id ? (
|
||||||
Bu kategori icin parca bulunamadi.
|
<Check className="size-3.5 text-green-500" />
|
||||||
</div>
|
) : (
|
||||||
|
<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>
|
||||||
</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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -112,44 +134,43 @@ export function SchemaViewer({
|
|||||||
onTouchMove={interaction.onTouchMove}
|
onTouchMove={interaction.onTouchMove}
|
||||||
onTouchEnd={interaction.onTouchEnd}
|
onTouchEnd={interaction.onTouchEnd}
|
||||||
>
|
>
|
||||||
{schemaPic ? (
|
<div
|
||||||
<div
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
className="absolute inset-0 flex items-center justify-center"
|
style={{
|
||||||
style={{
|
transform: `scale(${zoom}) translate(${panX / zoom}px, ${panY / zoom}px)`,
|
||||||
transform: `scale(${zoom}) translate(${panX / zoom}px, ${panY / zoom}px)`,
|
transformOrigin: "center center",
|
||||||
transformOrigin: "center center",
|
willChange: "transform",
|
||||||
willChange: "transform",
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<div className="relative">
|
||||||
<div className="relative">
|
<img
|
||||||
<img
|
src={schemaPic.url}
|
||||||
src={schemaPic.url}
|
alt={schemaPic.label || "Şema görüntüsü"}
|
||||||
alt={schemaPic.label || "Şema görüntüsü"}
|
width={schemaPic.width > 0 ? schemaPic.width : undefined}
|
||||||
width={schemaPic.width > 0 ? schemaPic.width : undefined}
|
height={schemaPic.height > 0 ? schemaPic.height : undefined}
|
||||||
height={schemaPic.height > 0 ? schemaPic.height : undefined}
|
className="max-h-full max-w-full select-none object-contain"
|
||||||
className="max-h-full max-w-full select-none object-contain"
|
draggable={false}
|
||||||
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>
|
||||||
) : (
|
</div>
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
||||||
Sema goruntusu bulunamadi
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side: Parts panel (40%) */}
|
{/* Right side: Parts panel (40%) */}
|
||||||
<div className="max-h-[500px] w-full md:max-h-none md:w-[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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -466,5 +466,8 @@
|
|||||||
"tr": "Türkçe",
|
"tr": "Türkçe",
|
||||||
"en": "English",
|
"en": "English",
|
||||||
"switchLanguage": "Switch Language"
|
"switchLanguage": "Switch Language"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"root": "Root"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -466,5 +466,8 @@
|
|||||||
"tr": "Türkçe",
|
"tr": "Türkçe",
|
||||||
"en": "English",
|
"en": "English",
|
||||||
"switchLanguage": "Dil Değiştir"
|
"switchLanguage": "Dil Değiştir"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"root": "Kök"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user