feat(manufacturers): detail page with MPN-level insights
CI / Lint · Typecheck · Test · Build (push) Successful in 47s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m2s

Adds /manufacturers/:id with vendor-wide KPIs, top MPNs by units,
failures by MPN, category mix, past-EOL exposure, and a filtered
PartModels table. Wires upstream links from PartDetail and
PartModelDetail so the manufacturer name is a navigable anchor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 15:10:37 -04:00
parent c6fb839005
commit 1d53e81d5e
14 changed files with 1027 additions and 30 deletions
+24
View File
@@ -18,6 +18,30 @@ export async function list(req: Request, res: Response, next: NextFunction) {
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const m = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
if (!m) throw errors.notFound('Manufacturer');
res.json(m);
} catch (err) {
next(err);
}
}
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('Manufacturer');
res.json(insights);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateManufacturerRequest;
+2
View File
@@ -11,6 +11,8 @@ import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', PaginationQuery), ctrl.list);
router.get('/:id', requireAuth, ctrl.get);
router.get('/:id/insights', requireAuth, ctrl.getInsights);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateManufacturerRequest), ctrl.create);
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateManufacturerRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
+227
View File
@@ -0,0 +1,227 @@
import { describe, expect, it } from 'vitest';
import type { Tx } from './types.js';
import { getInsights } from './manufacturers.js';
// Minimal in-memory tx double exercising manufacturers.getInsights().
// Only the calls the function makes are stubbed.
interface FakeArgs {
mfrExists: boolean;
totalPartModels: number;
totalParts: number;
priceAgg: {
sum: number | null;
avg: number | null;
min: number | null;
max: number | null;
count: number;
};
repairCount: number;
distinctFailedBrokenPartIds: string[];
fmCount: number;
// rows used by part.groupBy({ by: ['partModelId','state'] })
modelStateGroups: { partModelId: string; state: string; count: number }[];
// rows for the partModel.findMany(select: { id, mpn, category }) call
allModels: {
id: string;
mpn: string;
category: { id: string; name: string } | null;
}[];
// rows for the EOL partModel.findMany call
eolModels: { id: string; mpn: string; eolDate: Date | null }[];
// rows for repair.findMany(select: { brokenPart: { partModelId } }) call
repairsWithModel: { brokenPart: { partModelId: string } }[];
}
function makeTx(args: FakeArgs): Tx {
const tx = {
manufacturer: {
findUnique: async () => (args.mfrExists ? { id: 'mfr' } : null),
},
partModel: {
count: async () => args.totalPartModels,
findMany: async (opts: { where?: { eolDate?: unknown } }) => {
// Distinguish the two findMany calls by whether `where.eolDate` is set
if (opts?.where && 'eolDate' in opts.where) return args.eolModels;
return args.allModels;
},
},
part: {
count: async () => args.totalParts,
groupBy: async () =>
args.modelStateGroups.map((g) => ({
partModelId: g.partModelId,
state: g.state,
_count: { _all: g.count },
})),
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 (opts: { select?: { brokenPartId?: boolean } }) => {
// distinguish the two findMany calls by selected fields
if (opts?.select && 'brokenPartId' in opts.select) {
return args.distinctFailedBrokenPartIds.map((brokenPartId) => ({ brokenPartId }));
}
return args.repairsWithModel;
},
},
fm: {
count: async () => args.fmCount,
},
};
return tx as unknown as Tx;
}
const empty: FakeArgs = {
mfrExists: true,
totalPartModels: 0,
totalParts: 0,
priceAgg: { sum: null, avg: null, min: null, max: null, count: 0 },
repairCount: 0,
distinctFailedBrokenPartIds: [],
fmCount: 0,
modelStateGroups: [],
allModels: [],
eolModels: [],
repairsWithModel: [],
};
describe('manufacturers.getInsights', () => {
it('returns null when manufacturer does not exist', async () => {
const tx = makeTx({ ...empty, mfrExists: false });
const r = await getInsights(tx, 'nope');
expect(r).toBeNull();
});
it('aggregates totals and price stats', async () => {
const tx = makeTx({
...empty,
totalPartModels: 3,
totalParts: 12,
priceAgg: { sum: 1200, avg: 300, min: 100, max: 500, count: 4 },
});
const r = await getInsights(tx, 'mfr');
expect(r!.totalPartModels).toBe(3);
expect(r!.totalParts).toBe(12);
expect(r!.priceStats).toEqual({
total: 1200,
average: 300,
min: 100,
max: 500,
countWithPrice: 4,
});
});
it('zeros price stats when no parts priced', async () => {
const tx = makeTx({ ...empty, totalParts: 5 });
const r = await getInsights(tx, 'mfr');
expect(r!.priceStats).toEqual({
total: 0,
average: 0,
min: null,
max: null,
countWithPrice: 0,
});
});
it('counts repairs, distinct failed parts, and FMs implicating the manufacturer', async () => {
const tx = makeTx({
...empty,
repairCount: 5,
distinctFailedBrokenPartIds: ['p1', 'p2', 'p3'],
fmCount: 4,
});
const r = await getInsights(tx, 'mfr');
expect(r!.failures).toEqual({ repairs: 5, distinctFailedParts: 3, fmsImplicating: 4 });
});
it('derives topModelsByUnits from modelStateGroups, sorted desc, truncated to 8', async () => {
// 9 MPNs to confirm truncation. Count matches index+1 so pm9 is biggest.
const modelStateGroups = Array.from({ length: 9 }).map((_, i) => ({
partModelId: `pm${i + 1}`,
state: 'SPARE',
count: i + 1,
}));
const allModels = modelStateGroups.map((g) => ({
id: g.partModelId,
mpn: `MPN-${g.partModelId}`,
category: null,
}));
const tx = makeTx({ ...empty, modelStateGroups, allModels });
const r = await getInsights(tx, 'mfr');
expect(r!.topModelsByUnits).toHaveLength(8);
expect(r!.topModelsByUnits[0]).toEqual({ partModelId: 'pm9', mpn: 'MPN-pm9', count: 9 });
expect(r!.topModelsByUnits[7]).toEqual({ partModelId: 'pm2', mpn: 'MPN-pm2', count: 2 });
// pm1 (count=1) should be truncated out
expect(r!.topModelsByUnits.find((m) => m.partModelId === 'pm1')).toBeUndefined();
});
it('groups failuresByModel, joins MPN, and sorts desc', async () => {
const tx = makeTx({
...empty,
allModels: [
{ id: 'pmA', mpn: 'AAA', category: null },
{ id: 'pmB', mpn: 'BBB', category: null },
],
// pmA has 3 repairs, pmB has 1
repairsWithModel: [
{ brokenPart: { partModelId: 'pmA' } },
{ brokenPart: { partModelId: 'pmA' } },
{ brokenPart: { partModelId: 'pmA' } },
{ brokenPart: { partModelId: 'pmB' } },
],
});
const r = await getInsights(tx, 'mfr');
expect(r!.failuresByModel).toEqual([
{ partModelId: 'pmA', mpn: 'AAA', repairs: 3 },
{ partModelId: 'pmB', mpn: 'BBB', repairs: 1 },
]);
});
it('groups byCategory with Uncategorized fallback for null categories', async () => {
const tx = makeTx({
...empty,
allModels: [
{ id: '1', mpn: 'A', category: { id: 'c1', name: 'Network' } },
{ id: '2', mpn: 'B', category: { id: 'c1', name: 'Network' } },
{ id: '3', mpn: 'C', category: null },
],
});
const r = await getInsights(tx, 'mfr');
expect(r!.byCategory).toEqual([
{ categoryId: 'c1', categoryName: 'Network', count: 2 },
{ categoryId: null, categoryName: 'Uncategorized', count: 1 },
]);
});
it('pastEolModels includes only models with deployedCount > 0', async () => {
const tx = makeTx({
...empty,
modelStateGroups: [
{ partModelId: 'pmEOL1', state: 'DEPLOYED', count: 2 },
{ partModelId: 'pmEOL1', state: 'SPARE', count: 1 },
// pmEOL2 is past EOL but has NO deployed parts → should be filtered out
{ partModelId: 'pmEOL2', state: 'SPARE', count: 5 },
],
eolModels: [
{ id: 'pmEOL1', mpn: 'OLD-1', eolDate: new Date('2024-01-01') },
{ id: 'pmEOL2', mpn: 'OLD-2', eolDate: new Date('2024-01-01') },
],
});
const r = await getInsights(tx, 'mfr');
expect(r!.pastEolModels).toEqual([
{
partModelId: 'pmEOL1',
mpn: 'OLD-1',
eolDate: new Date('2024-01-01').toISOString(),
deployedCount: 2,
},
]);
});
});
+145
View File
@@ -1,12 +1,157 @@
import { Prisma } from '@vector/db';
import type {
CreateManufacturerRequest,
ManufacturerInsights,
UpdateManufacturerRequest,
PaginationQuery,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
export function get(tx: Tx, id: string) {
return tx.manufacturer.findUnique({
where: { id },
include: { _count: { select: { parts: true, partModels: true } } },
});
}
export async function getInsights(tx: Tx, id: string): Promise<ManufacturerInsights | null> {
const model = await tx.manufacturer.findUnique({ where: { id }, select: { id: true } });
if (!model) return null;
const now = new Date();
const [
totalPartModels,
totalParts,
priceAgg,
repairsCount,
distinctFailedParts,
fmsImplicating,
modelStateGroups,
allModels,
eolModels,
repairsWithModel,
] = await Promise.all([
tx.partModel.count({ where: { manufacturerId: id } }),
tx.part.count({ where: { manufacturerId: id } }),
tx.part.aggregate({
where: { manufacturerId: id, price: { not: null } },
_sum: { price: true },
_avg: { price: true },
_min: { price: true },
_max: { price: true },
_count: { _all: true },
}),
tx.repair.count({ where: { brokenPart: { manufacturerId: id } } }),
tx.repair.findMany({
where: { brokenPart: { manufacturerId: id } },
select: { brokenPartId: true },
distinct: ['brokenPartId'],
}),
tx.fm.count({ where: { problemParts: { some: { part: { manufacturerId: id } } } } }),
tx.part.groupBy({
by: ['partModelId', 'state'],
where: { manufacturerId: id },
_count: { _all: true },
}),
tx.partModel.findMany({
where: { manufacturerId: id },
select: {
id: true,
mpn: true,
category: { select: { id: true, name: true } },
},
}),
tx.partModel.findMany({
where: { manufacturerId: id, eolDate: { not: null, lte: now } },
select: { id: true, mpn: true, eolDate: true },
}),
tx.repair.findMany({
where: { brokenPart: { manufacturerId: id } },
select: { brokenPart: { select: { partModelId: true } } },
}),
]);
const mpnById = new Map(allModels.map((m) => [m.id, m.mpn]));
const unitsByModel = new Map<string, number>();
const deployedByModel = new Map<string, number>();
for (const row of modelStateGroups) {
const n = row._count._all;
unitsByModel.set(row.partModelId, (unitsByModel.get(row.partModelId) ?? 0) + n);
if (row.state === 'DEPLOYED') {
deployedByModel.set(row.partModelId, (deployedByModel.get(row.partModelId) ?? 0) + n);
}
}
const topModelsByUnits = [...unitsByModel.entries()]
.map(([partModelId, count]) => ({
partModelId,
mpn: mpnById.get(partModelId) ?? '',
count,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 8);
const failuresByModelMap = new Map<string, number>();
for (const r of repairsWithModel) {
const pmId = r.brokenPart.partModelId;
failuresByModelMap.set(pmId, (failuresByModelMap.get(pmId) ?? 0) + 1);
}
const failuresByModel = [...failuresByModelMap.entries()]
.map(([partModelId, repairs]) => ({
partModelId,
mpn: mpnById.get(partModelId) ?? '',
repairs,
}))
.sort((a, b) => b.repairs - a.repairs)
.slice(0, 8);
const categoryCounts = new Map<string, { id: string | null; name: string; count: number }>();
for (const m of allModels) {
const key = m.category?.id ?? 'uncategorized';
const name = m.category?.name ?? 'Uncategorized';
const entry = categoryCounts.get(key);
if (entry) entry.count += 1;
else categoryCounts.set(key, { id: m.category?.id ?? null, name, count: 1 });
}
const byCategory = [...categoryCounts.values()]
.map((c) => ({ categoryId: c.id, categoryName: c.name, count: c.count }))
.sort((a, b) => b.count - a.count);
const pastEolModels = eolModels
.map((m) => ({
partModelId: m.id,
mpn: m.mpn,
eolDate: m.eolDate ? m.eolDate.toISOString() : '',
deployedCount: deployedByModel.get(m.id) ?? 0,
}))
.filter((m) => m.deployedCount > 0)
.sort((a, b) => b.deployedCount - a.deployedCount);
return {
totalPartModels,
totalParts,
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: repairsCount,
distinctFailedParts: distinctFailedParts.length,
fmsImplicating,
},
byCategory,
topModelsByUnits,
failuresByModel,
pastEolModels,
};
}
export async function list(tx: Tx, q: PaginationQuery) {
const { page, pageSize } = q;
const [data, total] = await Promise.all([
+2
View File
@@ -12,6 +12,7 @@ import Parts from './pages/Parts.js';
import PartDetail from './pages/PartDetail.js';
import Locations from './pages/Locations.js';
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 Fms from './pages/Fms.js';
@@ -60,6 +61,7 @@ export default function App() {
<Route path="/parts/:id" element={<PartDetail />} />
<Route path="/locations" element={<Locations />} />
<Route path="/manufacturers" element={<Manufacturers />} />
<Route path="/manufacturers/:id" element={<ManufacturerDetail />} />
<Route path="/part-models" element={<PartModels />} />
<Route path="/part-models/:id" element={<PartModelDetail />} />
<Route path="/fms" element={<Fms />} />
+11
View File
@@ -1,5 +1,6 @@
import type {
CreateManufacturerRequest,
ManufacturerInsights,
UpdateManufacturerRequest,
} from '@vector/shared';
import { api } from './client.js';
@@ -15,6 +16,16 @@ export function listManufacturers(filters: ManufacturerListFilters = {}) {
return getList<Manufacturer>('/manufacturers', filters);
}
export async function getManufacturer(id: string): Promise<Manufacturer> {
const res = await api.get<Manufacturer>(`/manufacturers/${id}`);
return res.data;
}
export async function getManufacturerInsights(id: string): Promise<ManufacturerInsights> {
const res = await api.get<ManufacturerInsights>(`/manufacturers/${id}/insights`);
return res.data;
}
export async function createManufacturer(input: CreateManufacturerRequest): Promise<Manufacturer> {
const res = await api.post<Manufacturer>('/manufacturers', input);
return res.data;
+1
View File
@@ -15,6 +15,7 @@ export interface Manufacturer {
name: string;
createdAt: string;
updatedAt: string;
_count?: { parts: number; partModels: number };
}
export interface PartModel {
+1
View File
@@ -20,6 +20,7 @@ export const queryKeys = {
list: (filters?: Record<string, unknown>) =>
[...queryKeys.manufacturers.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.manufacturers.all, 'detail', id] as const,
insights: (id: string) => [...queryKeys.manufacturers.all, 'insights', id] as const,
},
sites: {
all: ['sites'] as const,
+495
View File
@@ -0,0 +1,495 @@
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 {
deleteManufacturer,
getManufacturer,
getManufacturerInsights,
} from '../lib/api/manufacturers.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 { ManufacturerFormDialog } from '../components/manufacturers/ManufacturerFormDialog.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { StatCard } from '../components/StatCard.js';
import type { PartModel } from '../lib/api/types.js';
const CATEGORY_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 ManufacturerDetail() {
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 mfrQuery = useQuery({
queryKey: queryKeys.manufacturers.detail(id!),
queryFn: () => getManufacturer(id!),
enabled: Boolean(id),
});
const insightsQuery = useQuery({
queryKey: queryKeys.manufacturers.insights(id!),
queryFn: () => getManufacturerInsights(id!),
enabled: Boolean(id),
});
const deleteMutation = useMutation({
mutationFn: () => deleteManufacturer(id!),
onSuccess: () => {
toast.success('Manufacturer deleted');
queryClient.invalidateQueries({ queryKey: queryKeys.manufacturers.all });
navigate('/manufacturers', { 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: 'category',
header: 'Category',
cell: ({ row }) =>
row.original.category ? (
<Badge variant="outline">{row.original.category.name}</Badge>
) : (
<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 (mfrQuery.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 (mfrQuery.isError || !mfrQuery.data) {
const msg =
mfrQuery.error instanceof ApiRequestError
? mfrQuery.error.body.message
: 'Manufacturer not found.';
return (
<Card>
<CardHeader>
<CardTitle>Manufacturer unavailable</CardTitle>
<CardDescription>{msg}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={() => navigate('/manufacturers')}>
<ArrowLeft className="h-4 w-4" />
Back to manufacturers
</Button>
</CardContent>
</Card>
);
}
const mfr = mfrQuery.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 categoryData =
insights?.byCategory.map((c) => ({
name: c.categoryName,
count: c.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('/manufacturers')}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-lg font-semibold tracking-tight">{mfr.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 vendor'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 from this manufacturer 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">Category mix</CardTitle>
<CardDescription>What kinds of parts this vendor supplies.</CardDescription>
</CardHeader>
<CardContent className="h-72">
{insightsQuery.isPending ? (
<Skeleton className="h-full w-full" />
) : categoryData.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={categoryData}
dataKey="count"
nameKey="name"
innerRadius={55}
outerRadius={90}
paddingAngle={2}
>
{categoryData.map((_c, i) => (
<Cell key={i} fill={CATEGORY_COLORS[i % CATEGORY_COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Failures by MPN</CardTitle>
<CardDescription>
Which of this vendor's models have failed most the "stop buying the X" signal.
</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 for this vendor.
</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={mfr.name} />
<DetailRow
label="# MPNs"
value={<span className="tabular-nums">{mfr._count?.partModels ?? '—'}</span>}
/>
<DetailRow
label="# parts"
value={<span className="tabular-nums">{mfr._count?.parts ?? '—'}</span>}
/>
<Separator className="my-2" />
<DetailRow label="Created" value={new Date(mfr.createdAt).toLocaleString()} />
<DetailRow label="Updated" value={new Date(mfr.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 this manufacturer supplies.</CardDescription>
</CardHeader>
<CardContent>
<DataTable<PartModel, Record<string, never>>
columns={modelColumns}
getRowId={(m) => m.id}
queryKey={(params) =>
queryKeys.partModels.list({
manufacturerId: id,
page: params.page,
pageSize: params.pageSize,
q: params.q,
})
}
queryFn={(params) =>
listPartModels({
manufacturerId: 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 from this manufacturer yet.
</div>
}
/>
</CardContent>
</Card>
<ManufacturerFormDialog
open={editOpen}
onOpenChange={setEditOpen}
manufacturer={mfr}
/>
<ConfirmDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
title="Delete manufacturer?"
description={`Remove ${mfr.name}. Fails if any parts or part models reference it.`}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate()}
/>
</div>
);
}
+43 -27
View File
@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Building, Edit, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import { Building, Edit, Eye, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
@@ -24,6 +25,7 @@ import { useAuth } from '../contexts/AuthContext.js';
export default function Manufacturers() {
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const navigate = useNavigate();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
@@ -46,7 +48,14 @@ export default function Manufacturers() {
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
cell: ({ row }) => (
<Link
to={`/manufacturers/${row.original.id}`}
className="font-medium hover:underline"
>
{row.original.name}
</Link>
),
},
{
accessorKey: 'createdAt',
@@ -61,33 +70,40 @@ export default function Manufacturers() {
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
size: 40,
cell: ({ row }) =>
isAdmin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
<Edit className="h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setDeleting(row.original)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onSelect={() => navigate(`/manufacturers/${row.original.id}`)}>
<Eye className="h-3.5 w-3.5" />
View
</DropdownMenuItem>
{isAdmin && (
<>
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
<Edit className="h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setDeleting(row.original)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
),
},
],
[isAdmin],
[isAdmin, navigate],
);
return (
+1 -1
View File
@@ -154,7 +154,7 @@ export default function PartDetail() {
label="Manufacturer"
value={
<Link
to="/manufacturers"
to={`/manufacturers/${part.manufacturerId}`}
className="text-foreground hover:underline"
>
{part.manufacturer.name}
+25 -2
View File
@@ -209,7 +209,16 @@ export default function PartModelDetail() {
<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.manufacturer ? (
<Link
to={`/manufacturers/${model.manufacturerId}`}
className="hover:underline"
>
{model.manufacturer.name}
</Link>
) : (
'—'
)}
{' · '}
{model.category?.name ?? 'Uncategorized'}
{eolDate && (
@@ -288,7 +297,21 @@ export default function PartModelDetail() {
</CardHeader>
<CardContent>
<dl className="space-y-2">
<DetailRow label="Manufacturer" value={model.manufacturer?.name ?? '—'} />
<DetailRow
label="Manufacturer"
value={
model.manufacturer ? (
<Link
to={`/manufacturers/${model.manufacturerId}`}
className="text-foreground hover:underline"
>
{model.manufacturer.name}
</Link>
) : (
'—'
)
}
/>
<DetailRow
label="MPN"
value={<span className="font-mono text-xs">{model.mpn}</span>}
+1
View File
@@ -19,3 +19,4 @@ export * from './saved-views.js';
export * from './csv-imports.js';
export * from './analytics.js';
export * from './part-model-insights.js';
export * from './manufacturer-insights.js';
@@ -0,0 +1,49 @@
export interface ManufacturerPriceStats {
total: number;
average: number;
min: number | null;
max: number | null;
countWithPrice: number;
}
export interface ManufacturerFailureStats {
repairs: number;
distinctFailedParts: number;
fmsImplicating: number;
}
export interface ManufacturerCategoryCount {
categoryId: string | null;
categoryName: string;
count: number;
}
export interface ManufacturerModelCount {
partModelId: string;
mpn: string;
count: number;
}
export interface ManufacturerModelFailureCount {
partModelId: string;
mpn: string;
repairs: number;
}
export interface ManufacturerPastEolModel {
partModelId: string;
mpn: string;
eolDate: string;
deployedCount: number;
}
export interface ManufacturerInsights {
totalPartModels: number;
totalParts: number;
priceStats: ManufacturerPriceStats;
failures: ManufacturerFailureStats;
byCategory: ManufacturerCategoryCount[];
topModelsByUnits: ManufacturerModelCount[];
failuresByModel: ManufacturerModelFailureCount[];
pastEolModels: ManufacturerPastEolModel[];
}