feat(part-models): detail page with fleet insights
CI / Playwright (smoke) (push) Has been cancelled
CI / Lint · Typecheck · Test · Build (push) Successful in 46s
CI / Build & push images (push) Successful in 1m6s

Adds /part-models/:id mirroring host/part detail pattern: KPIs for
units, spend, avg price, failure counts, and FMs implicating the
model, a state-breakdown bar chart, and the parts-of-this-model
table. New GET /part-models/:id/insights aggregates via part.groupBy
+ aggregate and repair/fm counts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 14:51:39 -04:00
parent 13e3444258
commit c6fb839005
13 changed files with 738 additions and 27 deletions
+14
View File
@@ -28,6 +28,20 @@ export async function get(req: Request<{ id: string }>, res: Response, next: Nex
} }
} }
export async function getInsights(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const insights = await prisma.$transaction((tx) => svc.getInsights(tx, req.params.id));
if (!insights) throw errors.notFound('Part model');
res.json(insights);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) { export async function create(req: Request, res: Response, next: NextFunction) {
try { try {
const input = req.validated!.body as CreatePartModelRequest; const input = req.validated!.body as CreatePartModelRequest;
+1
View File
@@ -12,6 +12,7 @@ const router = Router();
router.get('/', requireAuth, validate('query', PartModelListQuery), ctrl.list); router.get('/', requireAuth, validate('query', PartModelListQuery), ctrl.list);
router.get('/:id', requireAuth, ctrl.get); router.get('/:id', requireAuth, ctrl.get);
router.get('/:id/insights', requireAuth, ctrl.getInsights);
router.post( router.post(
'/', '/',
requireAuth, requireAuth,
+154
View File
@@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest';
import type { Tx } from './types.js';
import { getInsights } from './part-models.js';
// Minimal in-memory tx double exercising getInsights(). We only stub the
// calls that getInsights actually makes.
function makeTx(args: {
modelExists: boolean;
partCount: number;
stateRows: { state: string; count: number; totalPrice: number }[];
priceAgg: {
sum: number | null;
avg: number | null;
min: number | null;
max: number | null;
count: number;
};
repairCount: number;
distinctFailedBrokenPartIds: string[];
fmCount: number;
}): Tx {
const tx = {
partModel: {
findUnique: async () => (args.modelExists ? { id: 'pm' } : null),
},
part: {
count: async () => args.partCount,
groupBy: async () =>
args.stateRows.map((s) => ({
state: s.state,
_count: { _all: s.count },
_sum: { price: s.totalPrice },
})),
aggregate: async () => ({
_sum: { price: args.priceAgg.sum },
_avg: { price: args.priceAgg.avg },
_min: { price: args.priceAgg.min },
_max: { price: args.priceAgg.max },
_count: { _all: args.priceAgg.count },
}),
},
repair: {
count: async () => args.repairCount,
findMany: async () =>
args.distinctFailedBrokenPartIds.map((brokenPartId) => ({ brokenPartId })),
},
fm: {
count: async () => args.fmCount,
},
};
return tx as unknown as Tx;
}
describe('part-models.getInsights', () => {
it('returns null when model does not exist', async () => {
const tx = makeTx({
modelExists: false,
partCount: 0,
stateRows: [],
priceAgg: { sum: null, avg: null, min: null, max: null, count: 0 },
repairCount: 0,
distinctFailedBrokenPartIds: [],
fmCount: 0,
});
const r = await getInsights(tx, 'nope');
expect(r).toBeNull();
});
it('aggregates totalParts and groups by state', async () => {
const tx = makeTx({
modelExists: true,
partCount: 3,
stateRows: [
{ state: 'SPARE', count: 1, totalPrice: 100 },
{ state: 'DEPLOYED', count: 2, totalPrice: 800 },
],
priceAgg: { sum: 900, avg: 450, min: 100, max: 500, count: 2 },
repairCount: 0,
distinctFailedBrokenPartIds: [],
fmCount: 0,
});
const r = await getInsights(tx, 'pm');
expect(r).not.toBeNull();
expect(r!.totalParts).toBe(3);
expect(r!.byState).toEqual([
{ state: 'SPARE', count: 1, totalPrice: 100 },
{ state: 'DEPLOYED', count: 2, totalPrice: 800 },
]);
});
it('computes price stats from aggregate (countWithPrice drives average)', async () => {
const tx = makeTx({
modelExists: true,
partCount: 3,
stateRows: [],
// 2 priced parts ($100 + $500), 1 null-priced part
priceAgg: { sum: 600, avg: 300, min: 100, max: 500, count: 2 },
repairCount: 0,
distinctFailedBrokenPartIds: [],
fmCount: 0,
});
const r = await getInsights(tx, 'pm');
expect(r!.priceStats).toEqual({
total: 600,
average: 300,
min: 100,
max: 500,
countWithPrice: 2,
});
});
it('zeros price stats when no parts are priced', async () => {
const tx = makeTx({
modelExists: true,
partCount: 2,
stateRows: [],
priceAgg: { sum: null, avg: null, min: null, max: null, count: 0 },
repairCount: 0,
distinctFailedBrokenPartIds: [],
fmCount: 0,
});
const r = await getInsights(tx, 'pm');
expect(r!.priceStats).toEqual({
total: 0,
average: 0,
min: null,
max: null,
countWithPrice: 0,
});
});
it('counts repairs, distinct failed parts, and FMs implicating the model', async () => {
const tx = makeTx({
modelExists: true,
partCount: 5,
stateRows: [],
priceAgg: { sum: 0, avg: null, min: null, max: null, count: 0 },
repairCount: 3,
// one part failed twice → 2 distinct broken parts
distinctFailedBrokenPartIds: ['part-a', 'part-b'],
fmCount: 2,
});
const r = await getInsights(tx, 'pm');
expect(r!.failures).toEqual({
repairs: 3,
distinctFailedParts: 2,
fmsImplicating: 2,
});
});
});
+54
View File
@@ -1,6 +1,7 @@
import { Prisma } from '@vector/db'; import { Prisma } from '@vector/db';
import type { import type {
CreatePartModelRequest, CreatePartModelRequest,
PartModelInsights,
PartModelListQuery, PartModelListQuery,
UpdatePartModelRequest, UpdatePartModelRequest,
} from '@vector/shared'; } from '@vector/shared';
@@ -38,6 +39,59 @@ export function get(tx: Tx, id: string) {
return tx.partModel.findUnique({ where: { id }, include: partModelInclude }); return tx.partModel.findUnique({ where: { id }, include: partModelInclude });
} }
export async function getInsights(tx: Tx, id: string): Promise<PartModelInsights | null> {
const model = await tx.partModel.findUnique({ where: { id }, select: { id: true } });
if (!model) return null;
const [totalParts, stateRows, priceAgg, repairs, failedParts, fmsImplicating] = await Promise.all([
tx.part.count({ where: { partModelId: id } }),
tx.part.groupBy({
by: ['state'],
where: { partModelId: id },
_count: { _all: true },
_sum: { price: true },
}),
tx.part.aggregate({
where: { partModelId: id, price: { not: null } },
_sum: { price: true },
_avg: { price: true },
_min: { price: true },
_max: { price: true },
_count: { _all: true },
}),
tx.repair.count({ where: { brokenPart: { partModelId: id } } }),
tx.repair.findMany({
where: { brokenPart: { partModelId: id } },
select: { brokenPartId: true },
distinct: ['brokenPartId'],
}),
tx.fm.count({ where: { problemParts: { some: { part: { partModelId: id } } } } }),
]);
const byState = stateRows.map((row) => ({
state: row.state as PartModelInsights['byState'][number]['state'],
count: row._count._all,
totalPrice: row._sum.price ?? 0,
}));
return {
totalParts,
byState,
priceStats: {
total: priceAgg._sum.price ?? 0,
average: priceAgg._avg.price ?? 0,
min: priceAgg._min.price,
max: priceAgg._max.price,
countWithPrice: priceAgg._count._all,
},
failures: {
repairs,
distinctFailedParts: failedParts.length,
fmsImplicating,
},
};
}
export async function create(tx: Tx, input: CreatePartModelRequest) { export async function create(tx: Tx, input: CreatePartModelRequest) {
try { try {
return await tx.partModel.create({ return await tx.partModel.create({
+2
View File
@@ -13,6 +13,7 @@ import PartDetail from './pages/PartDetail.js';
import Locations from './pages/Locations.js'; import Locations from './pages/Locations.js';
import Manufacturers from './pages/Manufacturers.js'; import Manufacturers from './pages/Manufacturers.js';
import PartModels from './pages/PartModels.js'; import PartModels from './pages/PartModels.js';
import PartModelDetail from './pages/PartModelDetail.js';
import Fms from './pages/Fms.js'; import Fms from './pages/Fms.js';
import FmDetail from './pages/FmDetail.js'; import FmDetail from './pages/FmDetail.js';
import Repairs from './pages/Repairs.js'; import Repairs from './pages/Repairs.js';
@@ -60,6 +61,7 @@ export default function App() {
<Route path="/locations" element={<Locations />} /> <Route path="/locations" element={<Locations />} />
<Route path="/manufacturers" element={<Manufacturers />} /> <Route path="/manufacturers" element={<Manufacturers />} />
<Route path="/part-models" element={<PartModels />} /> <Route path="/part-models" element={<PartModels />} />
<Route path="/part-models/:id" element={<PartModelDetail />} />
<Route path="/fms" element={<Fms />} /> <Route path="/fms" element={<Fms />} />
<Route path="/fms/:id" element={<FmDetail />} /> <Route path="/fms/:id" element={<FmDetail />} />
<Route path="/repairs" element={<Repairs />} /> <Route path="/repairs" element={<Repairs />} />
+21
View File
@@ -0,0 +1,21 @@
import { Card, CardContent } from '@vector/ui';
interface StatCardProps {
label: string;
value: string;
sub?: string;
}
export function StatCard({ label, value, sub }: StatCardProps) {
return (
<Card>
<CardContent className="p-4">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="truncate text-2xl font-semibold tracking-tight">{value}</div>
{sub && <div className="mt-0.5 text-xs text-muted-foreground">{sub}</div>}
</CardContent>
</Card>
);
}
+6
View File
@@ -1,5 +1,6 @@
import type { import type {
CreatePartModelRequest, CreatePartModelRequest,
PartModelInsights,
UpdatePartModelRequest, UpdatePartModelRequest,
} from '@vector/shared'; } from '@vector/shared';
import { api } from './client.js'; import { api } from './client.js';
@@ -40,3 +41,8 @@ export async function updatePartModel(
export async function deletePartModel(id: string): Promise<void> { export async function deletePartModel(id: string): Promise<void> {
await api.delete(`/part-models/${id}`); await api.delete(`/part-models/${id}`);
} }
export async function getPartModelInsights(id: string): Promise<PartModelInsights> {
const res = await api.get<PartModelInsights>(`/part-models/${id}/insights`);
return res.data;
}
+1
View File
@@ -15,6 +15,7 @@ export type PartListFilters = {
state?: string; state?: string;
manufacturerId?: string; manufacturerId?: string;
categoryId?: string; categoryId?: string;
partModelId?: string;
binId?: string; binId?: string;
tagId?: string; tagId?: string;
eolOnly?: boolean; eolOnly?: boolean;
+1
View File
@@ -73,6 +73,7 @@ export const queryKeys = {
list: (filters?: Record<string, unknown>) => list: (filters?: Record<string, unknown>) =>
[...queryKeys.partModels.all, 'list', filters ?? {}] as const, [...queryKeys.partModels.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.partModels.all, 'detail', id] as const, detail: (id: string) => [...queryKeys.partModels.all, 'detail', id] as const,
insights: (id: string) => [...queryKeys.partModels.all, 'insights', id] as const,
}, },
tags: { tags: {
all: ['tags'] as const, all: ['tags'] as const,
+413
View File
@@ -0,0 +1,413 @@
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 { ArrowLeft, Edit, Trash2 } from 'lucide-react';
import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { toast } from 'sonner';
import type { PartState } from '@vector/shared';
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
Skeleton,
} from '@vector/ui';
import { deletePartModel, getPartModel, getPartModelInsights } from '../lib/api/part-models.js';
import { listParts } from '../lib/api/parts.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 { PartStateBadge } from '../components/parts/PartStateBadge.js';
import { PartModelFormDialog } from '../components/part-models/PartModelFormDialog.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { StatCard } from '../components/StatCard.js';
import type { Part } from '../lib/api/types.js';
const STATE_LABELS: Record<PartState, string> = {
SPARE: 'Spare',
DEPLOYED: 'Deployed',
BROKEN: 'Broken',
PENDING_DESTRUCTION: 'Pending destruction',
PENDING_DROP_IN_CUSTODY: 'In custody',
PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)',
PENDING_REPAIR: 'Held for repair',
};
const STATE_FILL: Record<PartState, string> = {
SPARE: 'hsl(217 91% 60%)',
DEPLOYED: 'hsl(142 71% 45%)',
BROKEN: 'hsl(0 84% 60%)',
PENDING_DESTRUCTION: 'hsl(38 92% 50%)',
PENDING_DROP_IN_CUSTODY: 'hsl(262 83% 58%)',
PENDING_DESTRUCTION_IN_CUSTODY: 'hsl(340 82% 52%)',
PENDING_REPAIR: 'hsl(197 80% 50%)',
};
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 PartModelDetail() {
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 modelQuery = useQuery({
queryKey: queryKeys.partModels.detail(id!),
queryFn: () => getPartModel(id!),
enabled: Boolean(id),
});
const insightsQuery = useQuery({
queryKey: queryKeys.partModels.insights(id!),
queryFn: () => getPartModelInsights(id!),
enabled: Boolean(id),
});
const deleteMutation = useMutation({
mutationFn: () => deletePartModel(id!),
onSuccess: () => {
toast.success('Part model deleted');
queryClient.invalidateQueries({ queryKey: queryKeys.partModels.all });
navigate('/part-models', { replace: true });
},
onError: (err) => {
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed');
},
});
const partColumns = useMemo<ColumnDef<Part>[]>(
() => [
{
accessorKey: 'serialNumber',
header: 'Serial',
cell: ({ row }) => (
<Link to={`/parts/${row.original.id}`} className="font-mono text-xs hover:underline">
{row.original.serialNumber}
</Link>
),
},
{
accessorKey: 'state',
header: 'State',
cell: ({ row }) => <PartStateBadge state={row.original.state} />,
},
{
id: 'location',
header: 'Location',
cell: ({ row }) => {
const p = row.original;
if (p.host) {
return (
<span className="font-mono text-xs">
{p.host.assetId} / {p.host.name}
</span>
);
}
if (p.custodian) {
return <span className="text-xs">Custody: {p.custodian.username}</span>;
}
if (p.bin?.fullPath) {
return <span className="font-mono text-xs">{p.bin.fullPath}</span>;
}
return <span className="text-xs text-muted-foreground italic">Unassigned</span>;
},
},
{
accessorKey: 'price',
header: 'Price',
cell: ({ row }) =>
row.original.price != null ? (
<span className="tabular-nums">{currency(row.original.price)}</span>
) : (
<span className="text-muted-foreground"></span>
),
},
],
[],
);
if (modelQuery.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 (modelQuery.isError || !modelQuery.data) {
const msg =
modelQuery.error instanceof ApiRequestError
? modelQuery.error.body.message
: 'Part model not found.';
return (
<Card>
<CardHeader>
<CardTitle>Part model unavailable</CardTitle>
<CardDescription>{msg}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={() => navigate('/part-models')}>
<ArrowLeft className="h-4 w-4" />
Back to part models
</Button>
</CardContent>
</Card>
);
}
const model = modelQuery.data;
const insights = insightsQuery.data;
const eolDate = model.eolDate ? new Date(model.eolDate) : null;
const pastEol = eolDate ? eolDate.getTime() <= Date.now() : false;
const failureRate =
insights && insights.totalParts > 0
? Math.round((insights.failures.repairs / insights.totalParts) * 100)
: null;
const chartData =
insights?.byState.map((s) => ({
name: STATE_LABELS[s.state],
state: s.state,
count: s.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('/part-models')}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="font-mono text-lg font-semibold tracking-tight">{model.mpn}</h1>
<p className="text-xs text-muted-foreground">
{model.manufacturer?.name ?? '—'}
{' · '}
{model.category?.name ?? 'Uncategorized'}
{eolDate && (
<>
{' · EOL '}
<span className={pastEol ? 'text-warning' : ''}>
{eolDate.toLocaleDateString()}
</span>
</>
)}
</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-5">
{insightsQuery.isPending || !insights ? (
Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-20" />)
) : (
<>
<StatCard label="Units" 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 units'
}
/>
<StatCard
label="Failures"
value={insights.failures.repairs.toLocaleString()}
sub={
failureRate != null
? `${failureRate}% of units · ${insights.failures.distinctFailedParts} distinct`
: undefined
}
/>
<StatCard
label="FMs implicated"
value={insights.failures.fmsImplicating.toLocaleString()}
/>
</>
)}
</div>
<div className="grid gap-4 md:grid-cols-[1fr_1.2fr]">
<Card>
<CardHeader>
<CardTitle className="text-base">Summary</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-2">
<DetailRow label="Manufacturer" value={model.manufacturer?.name ?? '—'} />
<DetailRow
label="MPN"
value={<span className="font-mono text-xs">{model.mpn}</span>}
/>
<DetailRow
label="Category"
value={
model.category?.name ?? (
<span className="text-muted-foreground italic">Uncategorized</span>
)
}
/>
<DetailRow
label="EOL"
value={
eolDate ? (
<span className={pastEol ? 'text-warning' : ''}>
{eolDate.toLocaleDateString()}
</span>
) : (
<span className="text-muted-foreground"></span>
)
}
/>
<DetailRow label="Destroy on fail" value={model.destroyOnFail ? 'Yes' : 'No'} />
<Separator className="my-2" />
<DetailRow label="Created" value={new Date(model.createdAt).toLocaleString()} />
<DetailRow label="Updated" value={new Date(model.updatedAt).toLocaleString()} />
</dl>
{model.notes && (
<>
<Separator className="my-3" />
<div>
<p className="mb-1 text-xs font-medium text-muted-foreground">Notes</p>
<p className="whitespace-pre-wrap text-sm text-foreground">{model.notes}</p>
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">State breakdown</CardTitle>
<CardDescription>How the fleet is distributed across lifecycle states.</CardDescription>
</CardHeader>
<CardContent className="h-72">
{insightsQuery.isPending ? (
<Skeleton className="h-full w-full" />
) : chartData.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No units of this model yet.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} 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" radius={[0, 4, 4, 0]}>
{chartData.map((s) => (
<Cell key={s.state} fill={STATE_FILL[s.state]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Parts of this model</CardTitle>
<CardDescription>Every unit tracked under {model.mpn}.</CardDescription>
</CardHeader>
<CardContent>
<DataTable<Part, Record<string, never>>
columns={partColumns}
getRowId={(p) => p.id}
queryKey={(params) =>
queryKeys.parts.list({
partModelId: id,
page: params.page,
pageSize: params.pageSize,
q: params.q,
sort: params.sort,
})
}
queryFn={(params) =>
listParts({
partModelId: id,
page: params.page,
pageSize: params.pageSize,
q: params.q,
sort: params.sort,
})
}
searchPlaceholder="Search serial..."
emptyState={
<div className="py-8 text-center text-sm text-muted-foreground">
No parts of this model yet.
</div>
}
/>
</CardContent>
</Card>
<PartModelFormDialog open={editOpen} onOpenChange={setEditOpen} partModel={model} />
<ConfirmDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
title="Delete part model?"
description={`Remove ${model.mpn}. Fails if any parts reference this model.`}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate()}
/>
</div>
);
}
+41 -27
View File
@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Check, Edit, Layers, MoreHorizontal, Plus, Trash2 } from 'lucide-react'; import { Check, Edit, Eye, Layers, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Badge, Badge,
@@ -25,6 +26,7 @@ import { useAuth } from '../contexts/AuthContext.js';
export default function PartModels() { export default function PartModels() {
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN'; const isAdmin = user?.role === 'ADMIN';
const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
@@ -55,7 +57,12 @@ export default function PartModels() {
accessorKey: 'mpn', accessorKey: 'mpn',
header: 'MPN', header: 'MPN',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-mono text-xs font-medium">{row.original.mpn}</span> <Link
to={`/part-models/${row.original.id}`}
className="font-mono text-xs font-medium hover:underline"
>
{row.original.mpn}
</Link>
), ),
}, },
{ {
@@ -104,33 +111,40 @@ export default function PartModels() {
id: 'actions', id: 'actions',
header: () => <span className="sr-only">Actions</span>, header: () => <span className="sr-only">Actions</span>,
size: 40, size: 40,
cell: ({ row }) => cell: ({ row }) => (
isAdmin ? ( <DropdownMenu>
<DropdownMenu> <DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild> <Button variant="ghost" size="icon" className="h-7 w-7">
<Button variant="ghost" size="icon" className="h-7 w-7"> <MoreHorizontal className="h-4 w-4" />
<MoreHorizontal className="h-4 w-4" /> </Button>
</Button> </DropdownMenuTrigger>
</DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-36">
<DropdownMenuContent align="end" className="w-36"> <DropdownMenuItem onSelect={() => navigate(`/part-models/${row.original.id}`)}>
<DropdownMenuItem onSelect={() => setEditing(row.original)}> <Eye className="h-3.5 w-3.5" />
<Edit className="h-3.5 w-3.5" /> View
Edit </DropdownMenuItem>
</DropdownMenuItem> {isAdmin && (
<DropdownMenuSeparator /> <>
<DropdownMenuItem <DropdownMenuItem onSelect={() => setEditing(row.original)}>
onSelect={() => setDeleting(row.original)} <Edit className="h-3.5 w-3.5" />
className="text-destructive focus:text-destructive" Edit
> </DropdownMenuItem>
<Trash2 className="h-3.5 w-3.5" /> <DropdownMenuSeparator />
Delete <DropdownMenuItem
</DropdownMenuItem> onSelect={() => setDeleting(row.original)}
</DropdownMenuContent> className="text-destructive focus:text-destructive"
</DropdownMenu> >
) : null, <Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
),
}, },
], ],
[isAdmin], [isAdmin, navigate],
); );
return ( return (
+1
View File
@@ -18,3 +18,4 @@ export * from './webhooks.js';
export * from './saved-views.js'; export * from './saved-views.js';
export * from './csv-imports.js'; export * from './csv-imports.js';
export * from './analytics.js'; export * from './analytics.js';
export * from './part-model-insights.js';
@@ -0,0 +1,29 @@
import { z } from 'zod';
import { PartState } from './enums.js';
export interface PartModelStateCount {
state: z.infer<typeof PartState>;
count: number;
totalPrice: number;
}
export interface PartModelPriceStats {
total: number;
average: number;
min: number | null;
max: number | null;
countWithPrice: number;
}
export interface PartModelFailureStats {
repairs: number;
distinctFailedParts: number;
fmsImplicating: number;
}
export interface PartModelInsights {
totalParts: number;
byState: PartModelStateCount[];
priceStats: PartModelPriceStats;
failures: PartModelFailureStats;
}