feat(categories): add schema image thumbnails to grid and tree views

Enrich category children with batch-fetched schema images and leaf detection
on the API side. Display thumbnails in grid cards with shimmer loading
placeholders and background prefetching. Add TanStack Query caching for
category children. Fix insert race condition with onConflictDoNothing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-02-12 20:47:50 +00:00
parent a8eb8f4bdc
commit 7b024df4d5
4 changed files with 284 additions and 88 deletions

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { eq, inArray, sql } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import { categories, vehicles, schemaPics, parts } from "../database/schema/core";
import { RedisService } from "../redis/redis.service";
@@ -194,7 +194,7 @@ export class CategoriesService {
.from(categories)
.where(eq(categories.parentId, categoryId));
if (children.length > 0) return children;
if (children.length > 0) return this.enrichWithSchemaImages(children);
// Fetch from PL24
const [category] = await this.db
@@ -251,13 +251,25 @@ export class CategoriesService {
source: "pl24" as const,
}));
children = await this.db.insert(categories).values(insertData).returning();
children = await this.db
.insert(categories)
.values(insertData)
.onConflictDoNothing()
.returning();
// If onConflict skipped some, re-fetch from DB
if (children.length < insertData.length) {
children = await this.db
.select()
.from(categories)
.where(eq(categories.parentId, categoryId));
}
// Invalidate tree cache since new subgroups were added
await this.redis.del(`cat:tree:${category.vehicleId}`);
}
return children;
return children.length > 0 ? this.enrichWithSchemaImages(children) : children;
}
/**
@@ -288,7 +300,7 @@ export class CategoriesService {
parts: [],
schemaPics: [],
hotspots: [],
children,
children: await this.enrichWithSchemaImages(children),
};
}
@@ -528,6 +540,42 @@ export class CategoriesService {
return { ...category, schemaPics: pics };
}
private async enrichWithSchemaImages(cats: (typeof categories.$inferSelect)[]) {
const catIds = cats.map((c) => c.id);
if (catIds.length === 0) return cats;
// Batch fetch schema images
const pics = await this.db
.select({ categoryId: schemaPics.categoryId, imageUrl: schemaPics.imageUrl })
.from(schemaPics)
.where(inArray(schemaPics.categoryId, catIds));
const picMap = new Map(pics.map((p) => [p.categoryId, p.imageUrl]));
// Batch check which categories have sub-children in DB
const childCounts = await this.db
.select({
parentId: categories.parentId,
count: sql<number>`count(*)::int`,
})
.from(categories)
.where(inArray(categories.parentId, catIds))
.groupBy(categories.parentId);
const childCountMap = new Map(childCounts.map((r) => [r.parentId, r.count]));
return cats.map((c) => {
const dbChildCount = childCountMap.get(c.id) || 0;
// Leaf if: has BOM linkPath, OR has no linkPath and no DB children
const isLeaf = c.linkPath?.includes("/bom/") || (!c.linkPath && dbChildCount === 0);
return {
...c,
schemaImageUrl: picMap.get(c.id) || null,
children: isLeaf ? [] : undefined,
};
});
}
private getImageDimensions(buf: Buffer, ext: string): { width: number; height: number } {
try {
if (ext === 'gif' && buf.length >= 10) {

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import { Link, useNavigate } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronRight, ArrowLeft, Loader2 } from "lucide-react";
import { Card, CardContent } from "@sase/ui";
import { api } from "@/lib/api-client";
@@ -10,6 +11,8 @@ interface Category {
name: string;
children?: Category[];
partCount?: number;
schemaImageUrl?: string | null;
parentId?: string | null;
}
interface CategoryGridProps {
@@ -25,28 +28,97 @@ interface BreadcrumbItem {
export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [breadcrumbs, setBreadcrumbs] = useState<BreadcrumbItem[]>([]);
const [currentCategories, setCurrentCategories] = useState(categories);
const [loading, setLoading] = useState(false);
const [prefetchingIds, setPrefetchingIds] = useState<Set<string>>(new Set());
// Prefetch schema images for leaf categories without images (sequential to avoid PL24 session conflicts)
const prefetchedRef = useRef<Set<string>>(new Set());
useEffect(() => {
const leafsWithoutImage = currentCategories.filter(
(c) =>
c.children !== undefined &&
c.children.length === 0 &&
!c.schemaImageUrl &&
!prefetchedRef.current.has(c.id),
);
if (leafsWithoutImage.length === 0) return;
for (const c of leafsWithoutImage) prefetchedRef.current.add(c.id);
setPrefetchingIds(new Set(leafsWithoutImage.map((c) => c.id)));
const parentId = currentCategories[0]?.parentId;
let didCancel = false;
// Sequential fetch to avoid PL24 session conflicts
(async () => {
for (const c of leafsWithoutImage) {
if (didCancel) break;
try {
await api.get(`/vehicles/${vehicleId}/categories/${c.id}`);
} catch {}
}
if (didCancel || !parentId) return;
try {
const refreshed = await api.get<Category[]>(
`/categories/${parentId}/children`,
);
if (didCancel || !refreshed?.length) return;
setCurrentCategories((prev) =>
prev.map((c) => {
const updated = refreshed.find((r) => r.id === c.id);
return updated?.schemaImageUrl
? { ...c, schemaImageUrl: updated.schemaImageUrl }
: c;
}),
);
} catch {}
if (!didCancel) setPrefetchingIds(new Set());
})();
return () => {
didCancel = true;
};
}, [currentCategories, vehicleId]);
const handleDrillDown = useCallback(
async (category: Category) => {
// If children are already loaded, drill down immediately
if (category.children && category.children.length > 0) {
// Show cached children immediately if available, then enrich from API
const cachedChildren = category.children && category.children.length > 0 ? category.children : null;
if (cachedChildren) {
setBreadcrumbs((prev) => [
...prev,
{ id: category.id, name: category.name, categories: currentCategories },
]);
setCurrentCategories(category.children);
setCurrentCategories(cachedChildren);
// Enrich with schema images from API in background
queryClient
.fetchQuery({
queryKey: ["category-children", category.id],
queryFn: () =>
api.get<Category[]>(`/categories/${category.id}/children`),
staleTime: 5 * 60 * 1000,
})
.then((enriched) => {
if (enriched?.length) setCurrentCategories(enriched);
})
.catch(() => {});
return;
}
// Fetch children from API
// Fetch children from API via TanStack Query cache
setLoading(true);
try {
const data = await api.get<Category[]>(
`/categories/${category.id}/children`,
);
const data = await queryClient.fetchQuery({
queryKey: ["category-children", category.id],
queryFn: () =>
api.get<Category[]>(`/categories/${category.id}/children`),
staleTime: 5 * 60 * 1000,
});
if (data && data.length > 0) {
setBreadcrumbs((prev) => [
...prev,
@@ -66,7 +138,7 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
setLoading(false);
}
},
[currentCategories, navigate, vehicleId],
[currentCategories, navigate, vehicleId, queryClient],
);
const handleBack = useCallback(() => {
@@ -167,6 +239,8 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
name={category.name}
partCount={category.partCount}
Icon={Icon}
schemaImageUrl={category.schemaImageUrl}
isLoading={prefetchingIds.has(category.id)}
/>
</Link>
);
@@ -183,6 +257,8 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
name={category.name}
partCount={category.partCount}
Icon={Icon}
schemaImageUrl={category.schemaImageUrl}
isLoading={prefetchingIds.has(category.id)}
/>
</button>
);
@@ -197,11 +273,49 @@ function CategoryCard({
name,
partCount,
Icon,
schemaImageUrl,
isLoading,
}: {
name: string;
partCount?: number;
Icon: React.ComponentType<{ className?: string }>;
schemaImageUrl?: string | null;
isLoading?: boolean;
}) {
const [imgLoaded, setImgLoaded] = useState(false);
if (schemaImageUrl || isLoading) {
return (
<Card className="border hover:border-foreground/20 hover:shadow-md transition-all cursor-pointer group h-full overflow-hidden">
<CardContent className="p-0">
<div className="relative aspect-[4/3] bg-muted overflow-hidden">
{(!schemaImageUrl || !imgLoaded) && (
<div className="absolute inset-0 bg-muted overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-background/60 to-transparent animate-[shimmer_1.5s_ease-in-out_infinite]" />
</div>
)}
{schemaImageUrl && (
<img
src={schemaImageUrl}
alt={name}
className={`w-full h-full object-contain p-2 group-hover:scale-105 transition-transform ${imgLoaded ? "opacity-100" : "opacity-0"}`}
onLoad={() => setImgLoaded(true)}
/>
)}
</div>
<div className="p-3 flex items-center justify-between gap-2">
<span className="font-medium text-sm line-clamp-2">{name}</span>
{partCount != null && partCount > 0 && (
<span className="text-xs text-muted-foreground flex-shrink-0">
{partCount} parça
</span>
)}
</div>
</CardContent>
</Card>
);
}
return (
<Card className="border hover:border-foreground/20 hover:shadow-md transition-all cursor-pointer group h-full">
<CardContent className="p-4">

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import { Link } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronRight, ChevronDown, Loader2 } from "lucide-react";
import { api } from "@/lib/api-client";
import { getCategoryIcon } from "@/lib/category-icons";
@@ -9,78 +10,91 @@ interface Category {
name: string;
children?: Category[];
partCount?: number;
schemaImageUrl?: string | null;
parentId?: string | null;
}
interface CategoryTreeProps {
categories: Category[];
vehicleId: string;
}
export function CategoryTree({ categories, vehicleId }: CategoryTreeProps) {
export function CategoryTree({ categories, vehicleId }: { categories: Category[]; vehicleId: string }) {
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">Kategori bulunamadi.</p>;
}
return (
<div className="space-y-1">
{categories.map((category) => (
<CategoryNode
key={category.id}
category={category}
vehicleId={vehicleId}
level={0}
/>
{categories.map((cat) => (
<CategoryNode key={cat.id} category={cat} vehicleId={vehicleId} level={0} parentPrefetching={false} />
))}
</div>
);
}
interface CategoryNodeProps {
category: Category;
vehicleId: string;
level: number;
}
function CategoryNode({ category, vehicleId, level }: CategoryNodeProps) {
function CategoryNode({ category, vehicleId, level, parentPrefetching }: {
category: Category; vehicleId: string; level: number; parentPrefetching: boolean;
}) {
const queryClient = useQueryClient();
const [expanded, setExpanded] = useState(false);
const [children, setChildren] = useState<Category[]>(category.children || []);
const [loading, setLoading] = useState(false);
const [fetched, setFetched] = useState(category.children !== undefined);
const [prefetching, setPrefetching] = useState(false);
const prefetchedRef = useRef(false);
const hasChildren = children.length > 0;
const isLeaf = fetched && children.length === 0;
const handleExpand = useCallback(async () => {
if (expanded) {
setExpanded(false);
return;
}
// If we already fetched children, just expand
if (fetched) {
setExpanded(true);
return;
}
// Lazy-load children from API
if (expanded) { setExpanded(false); return; }
if (fetched) { setExpanded(true); return; }
setLoading(true);
try {
const data = await api.get<Category[]>(`/categories/${category.id}/children`);
const data = await queryClient.fetchQuery({
queryKey: ["category-children", category.id],
queryFn: () => api.get<Category[]>(`/categories/${category.id}/children`),
staleTime: 5 * 60 * 1000,
});
setChildren(data || []);
setFetched(true);
setExpanded(true);
} catch {
// If fetch fails, mark as fetched (leaf node)
setFetched(true);
setExpanded(true);
} finally {
setLoading(false);
}
}, [expanded, fetched, category.id]);
}, [expanded, fetched, category.id, queryClient]);
// Prefetch schema images for leaf children when expanded
useEffect(() => {
if (!expanded || prefetchedRef.current) return;
const leafs = children.filter(
(c) => c.children !== undefined && c.children.length === 0 && !c.schemaImageUrl,
);
if (leafs.length === 0) return;
prefetchedRef.current = true;
setPrefetching(true);
const parentId = children[0]?.parentId;
let cancelled = false;
(async () => {
for (const c of leafs) {
if (cancelled) break;
try { await api.get(`/vehicles/${vehicleId}/categories/${c.id}`); } catch {}
}
if (cancelled || !parentId) { if (!cancelled) setPrefetching(false); return; }
try {
const refreshed = await api.get<Category[]>(`/categories/${parentId}/children`);
if (!cancelled && refreshed?.length) {
setChildren((prev) => prev.map((c) => {
const u = refreshed.find((r) => r.id === c.id);
return u?.schemaImageUrl ? { ...c, schemaImageUrl: u.schemaImageUrl } : c;
}));
}
} catch {}
if (!cancelled) setPrefetching(false);
})();
return () => { cancelled = true; };
}, [expanded, children, vehicleId]);
const Icon = getCategoryIcon(category.name);
const isShimmering = parentPrefetching && isLeaf && !category.schemaImageUrl;
return (
<div>
@@ -95,57 +109,72 @@ function CategoryNode({ category, vehicleId, level }: CategoryNodeProps) {
) : isLeaf ? (
<span className="h-5 w-5" />
) : (
<button
type="button"
onClick={handleExpand}
className="flex h-5 w-5 items-center justify-center rounded hover:bg-muted"
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
<button type="button" onClick={handleExpand}
className="flex h-5 w-5 items-center justify-center rounded hover:bg-muted">
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
)}
{(() => {
const Icon = getCategoryIcon(category.name);
return <Icon className="h-4 w-4 text-muted-foreground" />;
})()}
<SchemaIcon Icon={Icon} schemaImageUrl={category.schemaImageUrl} name={category.name} shimmer={isShimmering} />
{isLeaf ? (
<Link
to="/dashboard/vehicles/$id/categories/$categoryId"
<Link to="/dashboard/vehicles/$id/categories/$categoryId"
params={{ id: vehicleId, categoryId: category.id }}
className="flex-1 truncate hover:underline"
>
className="flex-1 truncate hover:underline">
{category.name}
</Link>
) : (
<button
type="button"
onClick={handleExpand}
className="flex-1 truncate text-left hover:underline"
>
<button type="button" onClick={handleExpand}
className="flex-1 truncate text-left hover:underline">
{category.name}
</button>
)}
{category.partCount != null && category.partCount > 0 && (
<span className="text-xs text-muted-foreground">
{category.partCount}
</span>
<span className="text-xs text-muted-foreground">{category.partCount}</span>
)}
</div>
{hasChildren && expanded && (
<div>
{children.map((child) => (
<CategoryNode
key={child.id}
category={child}
vehicleId={vehicleId}
level={level + 1}
/>
<CategoryNode key={child.id} category={child} vehicleId={vehicleId}
level={level + 1} parentPrefetching={prefetching} />
))}
</div>
)}
</div>
);
}
function SchemaIcon({ Icon, schemaImageUrl, name, shimmer }: {
Icon: React.ComponentType<{ className?: string }>;
schemaImageUrl?: string | null;
name: string;
shimmer?: boolean;
}) {
const [show, setShow] = useState(false);
if (shimmer) {
return (
<div className="h-4 w-4 rounded bg-muted overflow-hidden relative flex-shrink-0">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-background/60 to-transparent animate-[shimmer_1.5s_ease-in-out_infinite]" />
</div>
);
}
if (!schemaImageUrl) {
return <Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />;
}
return (
<div className="relative flex-shrink-0"
onMouseEnter={() => setShow(true)} onMouseLeave={() => setShow(false)}>
<Icon className="h-4 w-4 text-primary cursor-pointer" />
{show && (
<div className="absolute left-0 bottom-full mb-2 z-50 w-56 rounded-lg border bg-card shadow-lg overflow-hidden pointer-events-none">
<div className="aspect-[4/3] bg-muted">
<img src={schemaImageUrl} alt={name} className="w-full h-full object-contain p-1" />
</div>
<div className="px-2 py-1.5 text-xs font-medium truncate border-t">{name}</div>
</div>
)}
</div>
);
}

View File

@@ -37,6 +37,11 @@
}
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.dark {
--color-background: #0a0a0a;
--color-foreground: #fafafa;