feat(categories): detail page with fleet insights
Clicking a category anywhere in the app now opens /categories/:id with MPN breakdown, manufacturer mix, failures by MPN, and past-EOL exposure — a dual of the manufacturer detail page. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import Manufacturers from './pages/Manufacturers.js';
|
||||
import ManufacturerDetail from './pages/ManufacturerDetail.js';
|
||||
import PartModels from './pages/PartModels.js';
|
||||
import PartModelDetail from './pages/PartModelDetail.js';
|
||||
import CategoryDetail from './pages/CategoryDetail.js';
|
||||
import Fms from './pages/Fms.js';
|
||||
import FmDetail from './pages/FmDetail.js';
|
||||
import Repairs from './pages/Repairs.js';
|
||||
@@ -66,6 +67,7 @@ export default function App() {
|
||||
<Route path="/manufacturers/:id" element={<ManufacturerDetail />} />
|
||||
<Route path="/part-models" element={<PartModels />} />
|
||||
<Route path="/part-models/:id" element={<PartModelDetail />} />
|
||||
<Route path="/categories/:id" element={<CategoryDetail />} />
|
||||
<Route path="/fms" element={<Fms />} />
|
||||
<Route path="/fms/:id" element={<FmDetail />} />
|
||||
<Route path="/repairs" element={<Repairs />} />
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { CreateCategoryRequest, UpdateCategoryRequest } from '@vector/shared';
|
||||
import type {
|
||||
CategoryInsights,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
} from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { Category } from './types.js';
|
||||
@@ -7,6 +11,16 @@ export function listCategories(filters: { page?: number; pageSize?: number } = {
|
||||
return getList<Category>('/categories', filters);
|
||||
}
|
||||
|
||||
export async function getCategory(id: string): Promise<Category> {
|
||||
const res = await api.get<Category>(`/categories/${id}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCategoryInsights(id: string): Promise<CategoryInsights> {
|
||||
const res = await api.get<CategoryInsights>(`/categories/${id}/insights`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createCategory(input: CreateCategoryRequest): Promise<Category> {
|
||||
const res = await api.post<Category>('/categories', input);
|
||||
return res.data;
|
||||
|
||||
@@ -165,8 +165,10 @@ export interface Tag {
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
_count?: { partModels: number };
|
||||
}
|
||||
|
||||
export interface FmProblemPart {
|
||||
|
||||
@@ -86,6 +86,8 @@ export const queryKeys = {
|
||||
all: ['categories'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.categories.all, 'list', filters ?? {}] as const,
|
||||
detail: (id: string) => [...queryKeys.categories.all, 'detail', id] as const,
|
||||
insights: (id: string) => [...queryKeys.categories.all, 'insights', id] as const,
|
||||
},
|
||||
webhooks: {
|
||||
all: ['webhooks'] as const,
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle, ArrowLeft, Edit, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Separator,
|
||||
Skeleton,
|
||||
} from '@vector/ui';
|
||||
import {
|
||||
deleteCategory,
|
||||
getCategory,
|
||||
getCategoryInsights,
|
||||
updateCategory,
|
||||
} from '../lib/api/categories.js';
|
||||
import { listPartModels } from '../lib/api/part-models.js';
|
||||
import { ApiRequestError } from '../lib/api/client.js';
|
||||
import { queryKeys } from '../lib/queryKeys.js';
|
||||
import { useAuth } from '../contexts/AuthContext.js';
|
||||
import { DataTable } from '../components/data-table/DataTable.js';
|
||||
import { NamePromptDialog } from '../components/NamePromptDialog.js';
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||
import { StatCard } from '../components/StatCard.js';
|
||||
import type { PartModel } from '../lib/api/types.js';
|
||||
|
||||
const MANUFACTURER_COLORS = [
|
||||
'hsl(217 91% 60%)',
|
||||
'hsl(142 71% 45%)',
|
||||
'hsl(262 83% 58%)',
|
||||
'hsl(38 92% 50%)',
|
||||
'hsl(340 82% 52%)',
|
||||
'hsl(197 80% 50%)',
|
||||
'hsl(0 84% 60%)',
|
||||
'hsl(160 60% 40%)',
|
||||
];
|
||||
|
||||
const BAR_COLOR = 'hsl(217 91% 60%)';
|
||||
const FAILURE_COLOR = 'hsl(0 84% 60%)';
|
||||
|
||||
function currency(dollars: number): string {
|
||||
return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' });
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="text-foreground">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CategoryDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'ADMIN';
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const categoryQuery = useQuery({
|
||||
queryKey: queryKeys.categories.detail(id!),
|
||||
queryFn: () => getCategory(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const insightsQuery = useQuery({
|
||||
queryKey: queryKeys.categories.insights(id!),
|
||||
queryFn: () => getCategoryInsights(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: (name: string) => updateCategory(id!, { name }),
|
||||
onSuccess: () => {
|
||||
toast.success('Category renamed');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.categories.all });
|
||||
setEditOpen(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteCategory(id!),
|
||||
onSuccess: () => {
|
||||
toast.success('Category deleted');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.categories.all });
|
||||
navigate('/parts', { replace: true });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed');
|
||||
},
|
||||
});
|
||||
|
||||
const modelColumns = useMemo<ColumnDef<PartModel>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'mpn',
|
||||
header: 'MPN',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to={`/part-models/${row.original.id}`}
|
||||
className="font-mono text-xs font-medium hover:underline"
|
||||
>
|
||||
{row.original.mpn}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'manufacturer',
|
||||
header: 'Manufacturer',
|
||||
cell: ({ row }) =>
|
||||
row.original.manufacturer ? (
|
||||
<Link
|
||||
to={`/manufacturers/${row.original.manufacturer.id}`}
|
||||
className="text-sm hover:underline"
|
||||
>
|
||||
{row.original.manufacturer.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'eolDate',
|
||||
header: 'EOL',
|
||||
cell: ({ row }) => {
|
||||
const iso = row.original.eolDate;
|
||||
if (!iso) return <span className="text-sm text-muted-foreground">—</span>;
|
||||
const pastEol = new Date(iso).getTime() <= Date.now();
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{new Date(iso).toLocaleDateString()}</span>
|
||||
{pastEol && <Badge variant="destructive">Past EOL</Badge>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'deployedCount',
|
||||
header: 'Parts',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm tabular-nums">{row.original._count?.parts ?? 0}</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
if (categoryQuery.isPending) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (categoryQuery.isError || !categoryQuery.data) {
|
||||
const msg =
|
||||
categoryQuery.error instanceof ApiRequestError
|
||||
? categoryQuery.error.body.message
|
||||
: 'Category not found.';
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Category unavailable</CardTitle>
|
||||
<CardDescription>{msg}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" onClick={() => navigate('/parts')}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to parts
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const category = categoryQuery.data;
|
||||
const insights = insightsQuery.data;
|
||||
|
||||
const failureRate =
|
||||
insights && insights.totalParts > 0
|
||||
? Math.round((insights.failures.repairs / insights.totalParts) * 100)
|
||||
: null;
|
||||
|
||||
const topModelsData =
|
||||
insights?.topModelsByUnits.map((m) => ({
|
||||
name: m.mpn,
|
||||
count: m.count,
|
||||
})) ?? [];
|
||||
|
||||
const failuresData =
|
||||
insights?.failuresByModel.map((m) => ({
|
||||
name: m.mpn,
|
||||
repairs: m.repairs,
|
||||
})) ?? [];
|
||||
|
||||
const manufacturerData =
|
||||
insights?.byManufacturer.map((m) => ({
|
||||
name: m.manufacturerName,
|
||||
count: m.count,
|
||||
})) ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(-1)}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight">{category.name}</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{insights
|
||||
? `${insights.totalPartModels} MPNs · ${insights.totalParts} parts`
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdmin && (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{insightsQuery.isPending || !insights ? (
|
||||
Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-20" />)
|
||||
) : (
|
||||
<>
|
||||
<StatCard label="MPNs" value={insights.totalPartModels.toLocaleString()} />
|
||||
<StatCard label="Parts" value={insights.totalParts.toLocaleString()} />
|
||||
<StatCard label="Total spent" value={currency(insights.priceStats.total)} />
|
||||
<StatCard
|
||||
label="Avg price"
|
||||
value={
|
||||
insights.priceStats.countWithPrice > 0
|
||||
? currency(insights.priceStats.average)
|
||||
: '—'
|
||||
}
|
||||
sub={
|
||||
insights.priceStats.countWithPrice > 0
|
||||
? `${insights.priceStats.countWithPrice} priced`
|
||||
: 'No priced parts'
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Failures"
|
||||
value={insights.failures.repairs.toLocaleString()}
|
||||
sub={
|
||||
failureRate != null
|
||||
? `${failureRate}% of parts · ${insights.failures.distinctFailedParts} distinct`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="FMs implicated"
|
||||
value={insights.failures.fmsImplicating.toLocaleString()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Top MPNs by units</CardTitle>
|
||||
<CardDescription>
|
||||
Where this category's inventory is concentrated.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-72">
|
||||
{insightsQuery.isPending ? (
|
||||
<Skeleton className="h-full w-full" />
|
||||
) : topModelsData.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
No parts in this category yet.
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={topModelsData} layout="vertical" margin={{ left: 16 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} width={120} />
|
||||
<Tooltip cursor={{ fill: 'hsl(var(--accent) / 0.2)' }} />
|
||||
<Bar dataKey="count" fill={BAR_COLOR} radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Manufacturer mix</CardTitle>
|
||||
<CardDescription>Which vendors supply this category.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-72">
|
||||
{insightsQuery.isPending ? (
|
||||
<Skeleton className="h-full w-full" />
|
||||
) : manufacturerData.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
No MPNs yet.
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={manufacturerData}
|
||||
dataKey="count"
|
||||
nameKey="name"
|
||||
innerRadius={55}
|
||||
outerRadius={90}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{manufacturerData.map((_m, i) => (
|
||||
<Cell key={i} fill={MANUFACTURER_COLORS[i % MANUFACTURER_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Failures by MPN</CardTitle>
|
||||
<CardDescription>
|
||||
Which models in this category have failed most.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-72">
|
||||
{insightsQuery.isPending ? (
|
||||
<Skeleton className="h-full w-full" />
|
||||
) : failuresData.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
No failures recorded in this category.
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={failuresData} layout="vertical" margin={{ left: 16 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} width={120} />
|
||||
<Tooltip cursor={{ fill: 'hsl(var(--accent) / 0.2)' }} />
|
||||
<Bar dataKey="repairs" fill={FAILURE_COLOR} radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="space-y-2">
|
||||
<DetailRow label="Name" value={category.name} />
|
||||
{category.description && (
|
||||
<DetailRow label="Description" value={category.description} />
|
||||
)}
|
||||
<DetailRow
|
||||
label="# MPNs"
|
||||
value={
|
||||
<span className="tabular-nums">{category._count?.partModels ?? '—'}</span>
|
||||
}
|
||||
/>
|
||||
<Separator className="my-2" />
|
||||
<DetailRow label="Created" value={new Date(category.createdAt).toLocaleString()} />
|
||||
<DetailRow label="Updated" value={new Date(category.updatedAt).toLocaleString()} />
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{insights && insights.pastEolModels.length > 0 && (
|
||||
<Card className="border-warning/50 bg-warning/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
Past-EOL MPNs with deployed parts
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
These models have passed their end-of-life date — plan replacements.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pb-5">
|
||||
{insights.pastEolModels.map((m) => (
|
||||
<div
|
||||
key={m.partModelId}
|
||||
className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-mono text-xs font-medium">{m.mpn}</div>
|
||||
{m.eolDate && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
EOL {new Date(m.eolDate).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{m.deployedCount} deployed
|
||||
</span>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`/part-models/${m.partModelId}`}>View</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Part models</CardTitle>
|
||||
<CardDescription>Every MPN in this category.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable<PartModel, Record<string, never>>
|
||||
columns={modelColumns}
|
||||
getRowId={(m) => m.id}
|
||||
queryKey={(params) =>
|
||||
queryKeys.partModels.list({
|
||||
categoryId: id,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
q: params.q,
|
||||
})
|
||||
}
|
||||
queryFn={(params) =>
|
||||
listPartModels({
|
||||
categoryId: id,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
q: params.q,
|
||||
})
|
||||
}
|
||||
searchPlaceholder="Search MPN..."
|
||||
emptyState={
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
No MPNs in this category yet.
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<NamePromptDialog
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
title="Rename category"
|
||||
label="Name"
|
||||
initialValue={category.name}
|
||||
confirmLabel="Save"
|
||||
pending={renameMutation.isPending}
|
||||
onSubmit={(name) => renameMutation.mutate(name)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
onOpenChange={setConfirmDelete}
|
||||
title="Delete category?"
|
||||
description={`Remove ${category.name}. Fails if any part models reference it.`}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
pending={deleteMutation.isPending}
|
||||
onConfirm={() => deleteMutation.mutate()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,7 +122,11 @@ export default function ManufacturerDetail() {
|
||||
header: 'Category',
|
||||
cell: ({ row }) =>
|
||||
row.original.category ? (
|
||||
<Badge variant="outline">{row.original.category.name}</Badge>
|
||||
<Link to={`/categories/${row.original.category.id}`}>
|
||||
<Badge variant="outline" className="hover:bg-accent">
|
||||
{row.original.category.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
|
||||
@@ -220,7 +220,16 @@ export default function PartModelDetail() {
|
||||
'—'
|
||||
)}
|
||||
{' · '}
|
||||
{model.category?.name ?? 'Uncategorized'}
|
||||
{model.category ? (
|
||||
<Link
|
||||
to={`/categories/${model.category.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{model.category.name}
|
||||
</Link>
|
||||
) : (
|
||||
'Uncategorized'
|
||||
)}
|
||||
{eolDate && (
|
||||
<>
|
||||
{' · EOL '}
|
||||
@@ -319,7 +328,14 @@ export default function PartModelDetail() {
|
||||
<DetailRow
|
||||
label="Category"
|
||||
value={
|
||||
model.category?.name ?? (
|
||||
model.category ? (
|
||||
<Link
|
||||
to={`/categories/${model.category.id}`}
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
{model.category.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">Uncategorized</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,11 @@ export default function PartModels() {
|
||||
header: 'Category',
|
||||
cell: ({ row }) =>
|
||||
row.original.category ? (
|
||||
<Badge variant="outline">{row.original.category.name}</Badge>
|
||||
<Link to={`/categories/${row.original.category.id}`}>
|
||||
<Badge variant="outline" className="hover:bg-accent">
|
||||
{row.original.category.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
|
||||
@@ -141,9 +141,12 @@ export default function Parts() {
|
||||
header: 'Category',
|
||||
cell: ({ row }) =>
|
||||
row.original.partModel.category ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<Link
|
||||
to={`/categories/${row.original.partModel.category.id}`}
|
||||
className="text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
{row.original.partModel.category.name}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user